From 37233ab28b5ed45d7bf07785f0529fafdf599f21 Mon Sep 17 00:00:00 2001 From: myw Date: Tue, 14 Jul 2026 14:41:41 +0800 Subject: [PATCH] improvement --- layout/includes/page/shuoshuo.pug | 289 ++--- .../third-party/card-post-count/waline.pug | 2 +- layout/includes/third-party/chat/chatra.pug | 20 +- layout/includes/third-party/chat/crisp.pug | 3 +- layout/includes/third-party/chat/tidio.pug | 6 +- .../third-party/comments/disqusjs.pug | 2 +- .../comments/facebook_comments.pug | 2 +- layout/includes/third-party/math/mathjax.pug | 37 +- layout/includes/third-party/math/mermaid.pug | 228 ++-- layout/includes/third-party/pjax.pug | 5 +- scripts/filters/post_lazyload.js | 2 +- scripts/filters/random_cover.js | 83 +- scripts/helpers/inject_head_js.js | 126 +- source/js/main.js | 302 ++--- source/js/search/algolia.js | 1033 +++++++-------- source/js/search/local-search.js | 1134 ++++++++--------- source/js/tw_cn.js | 35 +- source/js/utils.js | 69 +- 18 files changed, 1718 insertions(+), 1660 deletions(-) diff --git a/layout/includes/page/shuoshuo.pug b/layout/includes/page/shuoshuo.pug index 3e5f15b..fd93334 100644 --- a/layout/includes/page/shuoshuo.pug +++ b/layout/includes/page/shuoshuo.pug @@ -54,6 +54,8 @@ (() => { const limitConfig = !{ JSON.stringify(page.limit || {}) } + const escapeHtml = str => String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') + const sortDataByDate = data => data.sort((a, b) => new Date(b.date) - new Date(a.date)) const filterDataByLimit = (data, limit) => { @@ -64,49 +66,47 @@ return data.filter(item => new Date(item.date) >= limitDate) } return data - }; + } + + const dateFormatter = new Intl.DateTimeFormat('en-GB', { + timeZone: !{JSON.stringify(config.timezone || '')} || Intl.DateTimeFormat().resolvedOptions().timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }) const formatToTimeZone = (date) => { const fullDate = date.length === 10 ? `${date} 00:00:00` : date - const visitorTimeZone = '#{config.timezone}' || Intl.DateTimeFormat().resolvedOptions().timeZone - const options = { - timeZone: visitorTimeZone, - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false - } - const [day, month, year, hour, minute, second] = new Intl.DateTimeFormat('en-GB', options) - .format(new Date(fullDate)) - .match(/\d+/g) + const [day, month, year, hour, minute, second] = dateFormatter.format(new Date(fullDate)).match(/\d+/g) return `${year}-${month}-${day} ${hour}:${minute}:${second}` } const addLazyload = str => { - const config = { + const lazyConfig = { enable: !{Boolean(enable)}, native: !{Boolean(native)}, - field: '!{field}', - placeholder: '!{url_for(placeholder)}', + field: !{JSON.stringify(field || '')}, + placeholder: !{JSON.stringify(url_for(placeholder))}, } - if (!config.enable || config.field !== 'site') return str + if (!lazyConfig.enable || lazyConfig.field !== 'site' || str.indexOf(' { - if (config.native) { + if (lazyConfig.native) { img.setAttribute('loading', 'lazy') } else { const src = img.getAttribute('src') img.setAttribute('data-lazy-src', src) - if (config.placeholder) { - img.setAttribute('src', config.placeholder) + if (lazyConfig.placeholder) { + img.setAttribute('src', lazyConfig.placeholder) } else { img.removeAttribute('src') } @@ -119,19 +119,18 @@ const itemsPerPage = 8 let totalPages = 0 let data = [] - let inputEventsAttached = false // Flag to mark if input event listeners have been added const renderData = (dataSlice) => { const content = dataSlice.map(item => { const formattedDate = formatToTimeZone(item.date) - const tags = item.tags && item.tags.map(tag => `${tag}`).join('') || '' - const commentButton = item.key && !{commentsJsLoad} + const tags = item.tags && item.tags.map(tag => `${escapeHtml(tag)}`).join('') || '' + const commentButton = item.key && !{commentsJsLoad || false} ? `
` : '' const commentContainer = item.key - ? `
` + ? `
` : '' return ` @@ -139,10 +138,10 @@
- +
-
${item.author || '!{config.author}'}
+
${item.author ? escapeHtml(item.author) : !{JSON.stringify(config.author || '')}}
@@ -165,70 +164,94 @@ btf.loadLightbox(document.querySelectorAll('#article-container img:not(.no-lightbox)')) } + const setupNavEvents = (nav) => { + nav.querySelector('.shuoshuo-prev-btn').addEventListener('click', () => { + if (currentPage > 1) { currentPage--; renderPage(currentPage) } + }) + nav.querySelector('.shuoshuo-next-btn').addEventListener('click', () => { + if (currentPage < totalPages) { currentPage++; renderPage(currentPage) } + }) + + const input = nav.querySelector('.shuoshuo-page-input') + + input.addEventListener('focus', e => { e.target.placeholder = '' }) + input.addEventListener('blur', e => { + if (!e.target.value.trim()) e.target.placeholder = currentPage + }) + + input.addEventListener('input', e => { + const value = parseInt(e.target.value) || 0 + let wasInvalid = false + + if (value > totalPages) { e.target.value = totalPages; wasInvalid = true } + else if (value < 1 && e.target.value !== '') { e.target.value = 1; wasInvalid = true } + + if (wasInvalid) { + e.target.classList.add('invalid') + setTimeout(() => e.target.classList.remove('invalid'), 500) + } + }) + + input.addEventListener('keydown', e => { + const value = e.target.value + e.key + + if (e.key === 'Enter' || e.key === 'Backspace' || e.key === 'Delete' || + e.key === 'ArrowLeft' || e.key === 'ArrowRight' || + e.key === 'Tab' || e.ctrlKey || e.metaKey) { + if (e.key === 'Enter') { + const inputValue = e.target.value.trim() + const inputPage = inputValue === '' ? currentPage : parseInt(inputValue) + if (inputPage >= 1 && inputPage <= totalPages && inputPage !== currentPage) { + currentPage = inputPage + renderPage(currentPage) + } else if (inputValue === '') { + renderPage(currentPage) + } + } + return + } + + if (!/^\d$/.test(e.key)) { + e.preventDefault() + return + } + + const newValue = parseInt(value) || 0 + if (newValue > totalPages || (value.length > 1 && newValue === 0)) { + e.preventDefault() + e.target.classList.add('invalid') + setTimeout(() => e.target.classList.remove('invalid'), 500) + } + }) + } + const renderNavigation = () => { const container = document.getElementById('article-container') - const existingNav = container.nextElementSibling - if (existingNav && existingNav.classList.contains('shuoshuo-navigation')) { - existingNav.remove() - } - + let nav = container.nextElementSibling const pageInfoTemplate = '#{__('pagination.page_info')}' const pageInfoText = pageInfoTemplate .replace(/\$\{current}/g, currentPage) .replace(/\$\{total}/g, totalPages) - const navHtml = ` -
- - ${pageInfoText} - - -
- ` - container.insertAdjacentHTML('afterend', navHtml) - - // Add input validation event listeners (only once) - if (!inputEventsAttached) { - setTimeout(() => { - const input = document.querySelector('.shuoshuo-page-input') - if (input) { - // Clear placeholder when clicking the input box - input.addEventListener('focus', (event) => { - event.target.placeholder = '' - }) - - // Restore placeholder if no content when losing focus - input.addEventListener('blur', (event) => { - if (!event.target.value.trim()) { - event.target.placeholder = currentPage - } - }) - - input.addEventListener('input', (event) => { - const value = parseInt(event.target.value) || 0 - let wasInvalid = false - - if (value > totalPages) { - event.target.value = totalPages - wasInvalid = true - } else if (value < 1 && event.target.value !== '') { - event.target.value = 1 - wasInvalid = true - } - - // If value is corrected, show red and shake effect - if (wasInvalid) { - event.target.classList.add('invalid') - setTimeout(() => { - event.target.classList.remove('invalid') - }, 500) - } - }) - - inputEventsAttached = true // Mark that event listeners have been added - } - }, 0) + if (!nav || !nav.classList.contains('shuoshuo-navigation')) { + nav = document.createElement('div') + nav.className = 'shuoshuo-navigation' + nav.innerHTML = ` + + + + + ` + container.insertAdjacentElement('afterend', nav) + setupNavEvents(nav) } + + nav.querySelector('.shuoshuo-page-info').textContent = pageInfoText + nav.querySelector('.shuoshuo-prev-btn').disabled = currentPage === 1 + nav.querySelector('.shuoshuo-next-btn').disabled = currentPage === totalPages + const input = nav.querySelector('.shuoshuo-page-input') + input.max = totalPages + input.placeholder = currentPage } const renderPage = (page) => { @@ -239,79 +262,14 @@ renderNavigation() } - window.shuoshuoPrevPage = () => { - if (currentPage > 1) { - currentPage-- - renderPage(currentPage) - } - } - - window.shuoshuoNextPage = () => { - if (currentPage < totalPages) { - currentPage++ - renderPage(currentPage) - } - } - - window.shuoshuoGoToPage = (page) => { - if (typeof page === 'number') { - // Directly jump to the specified page - if (page >= 1 && page <= totalPages && page !== currentPage) { - currentPage = page - renderPage(currentPage) - } - } else { - // Get page from input box - const input = document.querySelector('.shuoshuo-page-input') - const inputValue = input.value.trim() - const inputPage = inputValue === '' ? currentPage : parseInt(inputValue) - if (inputPage >= 1 && inputPage <= totalPages && inputPage !== currentPage) { - currentPage = inputPage - renderPage(currentPage) - } else if (inputValue === '') { - // If input box is empty, re-render current page (update placeholder) - renderPage(currentPage) - } - } - } - - window.shuoshuoHandleKeyDown = (event) => { - const input = event.target - const value = input.value + event.key - - // Allow delete, arrow keys, backspace, etc. - if (event.key === 'Enter' || event.key === 'Backspace' || event.key === 'Delete' || - event.key === 'ArrowLeft' || event.key === 'ArrowRight' || - event.key === 'Tab' || event.ctrlKey || event.metaKey) { - if (event.key === 'Enter') { - window.shuoshuoGoToPage() - } - return - } - - // Only allow numbers - if (!/^\d$/.test(event.key)) { - event.preventDefault() - return - } - - // Check if the value after input exceeds the range - const newValue = parseInt(value) || 0 - if (newValue > totalPages || (value.length > 1 && newValue === 0)) { - event.preventDefault() - // Add red and shake effect - input.classList.add('invalid') - setTimeout(() => { - input.classList.remove('invalid') - }, 500) - } - } - const loadShuoshuo = async () => { + const container = document.getElementById('article-container') try { let originData = [] if (!{Boolean(page.shuoshuo_url)}) { + container.innerHTML = '
' const response = await fetch('!{url_for(page.shuoshuo_url)}') + if (!response.ok) throw new Error(`HTTP ${response.status}`) originData = await response.json() } else { const dataElement = document.getElementById('shuoshuo-data') @@ -319,14 +277,33 @@ } data = filterDataByLimit(sortDataByDate(originData), limitConfig) - totalPages = Math.ceil(data.length / itemsPerPage) + if (data.length === 0) { + container.innerHTML = '
' + return + } + renderPage(currentPage) } catch (error) { console.error(error) + container.innerHTML = '
' } - }; + } - window.pjax ? loadShuoshuo() : window.addEventListener('load', loadShuoshuo) - })() \ No newline at end of file + const pjaxCleanup = () => { + const container = document.getElementById('article-container') + if (container) { + const nav = container.nextElementSibling + if (nav && nav.classList.contains('shuoshuo-navigation')) nav.remove() + } + document.removeEventListener('pjax:send', pjaxCleanup) + } + + if (window.pjax) { + document.addEventListener('pjax:send', pjaxCleanup) + loadShuoshuo() + } else { + window.addEventListener('load', loadShuoshuo) + } + })() diff --git a/layout/includes/third-party/card-post-count/waline.pug b/layout/includes/third-party/card-post-count/waline.pug index 96e9657..389d9ea 100644 --- a/layout/includes/third-party/card-post-count/waline.pug +++ b/layout/includes/third-party/card-post-count/waline.pug @@ -8,7 +8,7 @@ script. const res = await fetch(`!{serverURL}/api/comment?type=count&url=${keyArray}`, { method: 'GET' }) const result = await res.json() - + result.data.forEach((count, index) => { eleGroup[index].textContent = count }) diff --git a/layout/includes/third-party/chat/chatra.pug b/layout/includes/third-party/chat/chatra.pug index e4a6900..3279d77 100644 --- a/layout/includes/third-party/chat/chatra.pug +++ b/layout/includes/third-party/chat/chatra.pug @@ -6,10 +6,14 @@ script. (window.Chatra.q = window.Chatra.q || []).push(arguments) } - btf.getScript('https://call.chatra.io/chatra.js').then(() => { - const isChatBtn = !{theme.chat.rightside_button} - const isChatHideShow = !{theme.chat.button_hide_show} + const isChatBtn = !{theme.chat.rightside_button} + const isChatHideShow = !{theme.chat.button_hide_show} + if (isChatBtn) { + window.ChatraSetup = { startHidden: true } + } + + btf.getScript('https://call.chatra.io/chatra.js').then(() => { if (isChatBtn) { const close = () => { Chatra('minimizeWidget') @@ -21,11 +25,13 @@ script. Chatra('show') } - window.ChatraSetup = { startHidden: true } - - window.chatBtnFn = () => document.getElementById('chatra').classList.contains('chatra--expanded') ? close() : open() + window.chatBtnFn = () => { + const el = document.getElementById('chatra') + return el && el.classList.contains('chatra--expanded') ? close() : open() + } - document.getElementById('chat-btn').style.display = 'block' + const chatBtn = document.getElementById('chat-btn') + if (chatBtn) chatBtn.style.display = 'block' } else if (isChatHideShow) { window.chatBtn = { hide: () => Chatra('hide'), diff --git a/layout/includes/third-party/chat/crisp.pug b/layout/includes/third-party/chat/crisp.pug index 6485566..38e094b 100644 --- a/layout/includes/third-party/chat/crisp.pug +++ b/layout/includes/third-party/chat/crisp.pug @@ -21,7 +21,8 @@ script. window.chatBtnFn = () => $crisp.is("chat:visible") ? close() : open() - document.getElementById('chat-btn').style.display = 'block' + const chatBtn = document.getElementById('chat-btn') + if (chatBtn) chatBtn.style.display = 'block' } else if (isChatHideShow) { window.chatBtn = { hide: () => $crisp.push(["do", "chat:hide"]), diff --git a/layout/includes/third-party/chat/tidio.pug b/layout/includes/third-party/chat/tidio.pug index 83ca1ec..0ee4ae0 100644 --- a/layout/includes/third-party/chat/tidio.pug +++ b/layout/includes/third-party/chat/tidio.pug @@ -10,7 +10,7 @@ script. window.tidioChatApi.hide() isShow = false } - + const open = () => { window.tidioChatApi.open() window.tidioChatApi.show() @@ -32,8 +32,8 @@ script. isShow ? close() : open() } - document.getElementById('chat-btn').style.display = 'block' - + const chatBtn = document.getElementById('chat-btn') + if (chatBtn) chatBtn.style.display = 'block' } else if (isChatHideShow) { window.chatBtn = { hide: () => window.tidioChatApi && window.tidioChatApi.hide(), diff --git a/layout/includes/third-party/comments/disqusjs.pug b/layout/includes/third-party/comments/disqusjs.pug index ef2a235..ddf5b37 100644 --- a/layout/includes/third-party/comments/disqusjs.pug +++ b/layout/includes/third-party/comments/disqusjs.pug @@ -3,7 +3,7 @@ script. (() => { - const isShuoshuo = GLOBAL_CONFIG_SITE.pageType === 'shuoshuo'== 'shuoshuo' + const isShuoshuo = GLOBAL_CONFIG_SITE.pageType === 'shuoshuo' const dqOption = !{JSON.stringify(dqOption)} const destroyDisqusjs = () => { diff --git a/layout/includes/third-party/comments/facebook_comments.pug b/layout/includes/third-party/comments/facebook_comments.pug index 25945db..bd8efbb 100644 --- a/layout/includes/third-party/comments/facebook_comments.pug +++ b/layout/includes/third-party/comments/facebook_comments.pug @@ -3,7 +3,7 @@ script. (()=>{ - const isShuoshuo = GLOBAL_CONFIG_SITE.pageType === 'shuoshuo'== 'shuoshuo' + const isShuoshuo = GLOBAL_CONFIG_SITE.pageType === 'shuoshuo' const loadFBComment = (el = document, path) => { if (isShuoshuo) { diff --git a/layout/includes/third-party/math/mathjax.pug b/layout/includes/third-party/math/mathjax.pug index b22d993..1e85bd6 100644 --- a/layout/includes/third-party/math/mathjax.pug +++ b/layout/includes/third-party/math/mathjax.pug @@ -2,7 +2,22 @@ - const { tags, enableMenu } = theme.math.mathjax script. (() => { + const changeScriptToMath = article => { + article.querySelectorAll('script[type^="math/tex"]').forEach(el => { + const display = /mode=display/.test(el.type) + const node = document.createElement(display ? 'div' : 'span') + node.textContent = display + ? `$$${el.textContent}$$` + : `$${el.textContent}$` + el.parentNode.replaceChild(node, el) + }) + } + const loadMathjax = () => { + const article = document.getElementById('article-container') + if (!article) return + changeScriptToMath(article) + if (!window.MathJax) { window.MathJax = { loader: { @@ -11,7 +26,8 @@ script. //- '[tex]/bbm', //- '[tex]/bboldx', //- '[tex]/dsfont', - '[tex]/mhchem' + '[tex]/mhchem', + 'ui/lazy' ], paths: { 'mathjax-newcm': '[mathjax]/../@mathjax/mathjax-newcm-font', @@ -39,25 +55,13 @@ script. scale: 1.1 }, options: { + lazyMargin: '200px', enableMenu: !{enableMenu}, menuOptions: { settings: { enrich: false // Turn off Braille and voice narration text automatic generation } }, - renderActions: { - findScript: [10, doc => { - for (const node of document.querySelectorAll('script[type^="math/tex"]')) { - const display = !!node.type.match(/; *mode=display/) - const math = new doc.options.MathItem(node.textContent, doc.inputJax[0], display) - const text = document.createTextNode('') - node.parentNode.replaceChild(text, node) - math.start = {node: text, delim: '', n: 0} - math.end = {node: text, delim: '', n: 0} - doc.math.push(math) - } - }, ''] - } } } @@ -67,9 +71,8 @@ script. script.async = true document.head.appendChild(script) } else { - MathJax.startup.document.state(0) - MathJax.texReset() - MathJax.typesetPromise() + MathJax.typesetClear() + MathJax.typesetPromise([ article ]) } } diff --git a/layout/includes/third-party/math/mermaid.pug b/layout/includes/third-party/math/mermaid.pug index f1d26b0..475d619 100644 --- a/layout/includes/third-party/math/mermaid.pug +++ b/layout/includes/third-party/math/mermaid.pug @@ -51,7 +51,7 @@ script. clone.setAttribute('viewBox', initViewBox.join(' ')) } if (!clone.getAttribute('xmlns')) clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg') - if (!clone.getAttribute('xmlns:xlink') && clone.outerHTML.includes('xlink:')) { + if (!clone.getAttribute('xmlns:xlink') && clone.innerHTML.includes('xlink:')) { clone.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink') } // inject background to match current theme @@ -70,7 +70,8 @@ script. const blob = new Blob([htmlSource], { type: 'text/html;charset=utf-8' }) const url = URL.createObjectURL(blob) window.open(url, '_blank', 'noopener') - setTimeout(() => URL.revokeObjectURL(url), 30000) + + setTimeout(() => URL.revokeObjectURL(url), 5000) } const attachMermaidViewerButton = wrap => { @@ -91,10 +92,6 @@ script. const svg = wrap.__mermaidOriginalSvg || wrap.querySelector('svg') if (!svg) return const initViewBox = wrap.__mermaidInitViewBox - if (typeof svg === 'string') { - openSvgInNewTab({ source: svg, initViewBox }) - return - } openSvgInNewTab({ source: svg, initViewBox }) }) btn.__mermaidViewerBound = true @@ -111,6 +108,13 @@ script. } const initMermaidGestures = wrap => { + // Clean up previous event listeners and pending frames + if (wrap.__mermaidAbortController) { + wrap.__mermaidAbortController.abort() + if (wrap.__mermaidRafId) cancelAnimationFrame(wrap.__mermaidRafId) + } + const ac = new AbortController() + wrap.__mermaidAbortController = ac const svg = wrap.querySelector('svg') if (!svg) return @@ -119,158 +123,177 @@ script. wrap.__mermaidInitViewBox = initVb wrap.__mermaidCurViewBox = initVb.slice() setSvgViewBox(svg, initVb) + // Disable default gestures to prevent scroll chaining and pinch-zoom penetration in Chrome + svg.style.touchAction = 'none' - // Avoid binding multiple times on themeChange/pjax - if (wrap.__mermaidGestureBound) return - wrap.__mermaidGestureBound = true + // Cache BoundingClientRect, throttled on scroll to reduce reflow + let cachedRect = svg.getBoundingClientRect() + let rectDirty = false + const markRectDirty = () => { rectDirty = true } + window.addEventListener('resize', markRectDirty, { signal: ac.signal }) + window.addEventListener('scroll', markRectDirty, { signal: ac.signal, capture: true }) + const getRect = () => { + if (rectDirty) { + cachedRect = svg.getBoundingClientRect() + rectDirty = false + } + return cachedRect + } - // Helper: map client (viewport) coordinate -> viewBox coordinate - const clientToViewBox = (clientX, clientY) => { - const rect = svg.getBoundingClientRect() - const vb = wrap.__mermaidCurViewBox || getSvgViewBox(svg) - const x = vb[0] + (clientX - rect.left) * (vb[2] / rect.width) - const y = vb[1] + (clientY - rect.top) * (vb[3] / rect.height) - return { x, y, rect, vb } + // Precompute clamp bounds from initial viewBox + const minW = initVb[2] * 0.1 + const maxW = initVb[2] * 10 + const minH = initVb[3] * 0.1 + const maxH = initVb[3] * 10 + const clampVb = vb => { + const out = vb.slice() + out[2] = clamp(out[2], minW, maxW) + out[3] = clamp(out[3], minH, maxH) + return out + } + + // Throttle DOM updates using requestAnimationFrame + let pendingVb = null + let rafId = null + const applyVb = () => { + if (pendingVb) { + wrap.__mermaidCurViewBox = pendingVb + setSvgViewBox(svg, pendingVb) + pendingVb = null + } + rafId = null + wrap.__mermaidRafId = null + } + const setCurVb = vb => { + pendingVb = clampVb(vb) + if (!rafId) { + rafId = requestAnimationFrame(applyVb) + wrap.__mermaidRafId = rafId + } } const state = { pointers: new Map(), startVb: null, startDist: 0, - startCenter: null - } - - const clampVb = vb => { - const init = wrap.__mermaidInitViewBox || vb - const minW = init[2] * 0.1 - const maxW = init[2] * 10 - const minH = init[3] * 0.1 - const maxH = init[3] * 10 - vb[2] = clamp(vb[2], minW, maxW) - vb[3] = clamp(vb[3], minH, maxH) - return vb - } - - const setCurVb = vb => { - vb = clampVb(vb) - wrap.__mermaidCurViewBox = vb - setSvgViewBox(svg, vb) + lastPointerX: 0, + lastPointerY: 0 } const onPointerDown = e => { - // Allow only primary button for mouse if (e.pointerType === 'mouse' && e.button !== 0) return svg.setPointerCapture(e.pointerId) + const curVb = wrap.__mermaidCurViewBox state.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }) - if (state.pointers.size === 1) { - state.startVb = (wrap.__mermaidCurViewBox || getSvgViewBox(svg)).slice() + state.startVb = curVb.slice() + state.lastPointerX = e.clientX + state.lastPointerY = e.clientY } else if (state.pointers.size === 2) { const pts = [...state.pointers.values()] const dx = pts[0].x - pts[1].x const dy = pts[0].y - pts[1].y state.startDist = Math.hypot(dx, dy) - state.startVb = (wrap.__mermaidCurViewBox || getSvgViewBox(svg)).slice() - state.startCenter = { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 } + state.startVb = curVb.slice() } } const onPointerMove = e => { if (!state.pointers.has(e.pointerId)) return state.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }) - - // Pan with 1 pointer + const curVb = wrap.__mermaidCurViewBox + const rect = getRect() if (state.pointers.size === 1 && state.startVb) { - const p = [...state.pointers.values()][0] - const prev = { x: e.clientX - e.movementX, y: e.clientY - e.movementY } - // movementX/Y unreliable on touch, compute from stored last position - const last = wrap.__mermaidLastSinglePointer || p - const dxClient = p.x - last.x - const dyClient = p.y - last.y - wrap.__mermaidLastSinglePointer = p - - const { rect } = clientToViewBox(p.x, p.y) - const vb = (wrap.__mermaidCurViewBox || getSvgViewBox(svg)).slice() - const dx = dxClient * (vb[2] / rect.width) - const dy = dyClient * (vb[3] / rect.height) - setCurVb([vb[0] - dx, vb[1] - dy, vb[2], vb[3]]) + const p = state.pointers.values().next().value + const dxClient = p.x - state.lastPointerX + const dyClient = p.y - state.lastPointerY + state.lastPointerX = p.x + state.lastPointerY = p.y + const dx = dxClient * (curVb[2] / rect.width) + const dy = dyClient * (curVb[3] / rect.height) + setCurVb([curVb[0] - dx, curVb[1] - dy, curVb[2], curVb[3]]) return } - - // Pinch zoom with 2 pointers if (state.pointers.size === 2 && state.startVb && state.startDist > 0) { const pts = [...state.pointers.values()] const dx = pts[0].x - pts[1].x const dy = pts[0].y - pts[1].y const dist = Math.hypot(dx, dy) if (!dist) return - const factor = state.startDist / dist // dist bigger => zoom in (viewBox smaller) - + const factor = state.startDist / dist const cx = (pts[0].x + pts[1].x) / 2 const cy = (pts[0].y + pts[1].y) / 2 - const centerClient = { x: cx, y: cy } - - const pxy = clientToViewBox(centerClient.x, centerClient.y) - const cpx = pxy.x - const cpy = pxy.y - - const vb = zoomAtPoint(state.startVb, factor, cpx, cpy) - setCurVb(vb) + const px = curVb[0] + (cx - rect.left) * (curVb[2] / rect.width) + const py = curVb[1] + (cy - rect.top) * (curVb[3] / rect.height) + setCurVb(zoomAtPoint(state.startVb, factor, px, py)) } } const onPointerUpOrCancel = e => { + // Release PointerCapture to avoid event capture anomalies + if (svg.hasPointerCapture && svg.hasPointerCapture(e.pointerId)) { + svg.releasePointerCapture(e.pointerId) + } state.pointers.delete(e.pointerId) if (state.pointers.size === 0) { state.startVb = null state.startDist = 0 - state.startCenter = null - wrap.__mermaidLastSinglePointer = null } else if (state.pointers.size === 1) { - // reset single pointer baseline to avoid jump - wrap.__mermaidLastSinglePointer = [...state.pointers.values()][0] + const p = state.pointers.values().next().value + state.lastPointerX = p.x + state.lastPointerY = p.y } } - // Wheel zoom (mouse/trackpad) const onWheel = e => { - // ctrlKey on mac trackpad pinch; we treat both as zoom + // Prevent event bubbling from triggering external scroll e.preventDefault() - const delta = e.deltaY - const zoomFactor = delta > 0 ? 1.1 : 0.9 - const { x, y } = clientToViewBox(e.clientX, e.clientY) - const vb = (wrap.__mermaidCurViewBox || getSvgViewBox(svg)).slice() - setCurVb(zoomAtPoint(vb, zoomFactor, x, y)) + e.stopPropagation() + // Normalize deltaY across deltaMode (Chrome uses pixels, Safari uses lines) + let delta = e.deltaY + if (e.deltaMode === 1) delta *= 16 + else if (e.deltaMode === 2) delta *= 400 + + // Continuous zoom factor: smoother than fixed 1.1/0.9 steps + const zoomFactor = Math.pow(1.001, delta) + + const curVb = wrap.__mermaidCurViewBox + const rect = getRect() + const px = curVb[0] + (e.clientX - rect.left) * (curVb[2] / rect.width) + const py = curVb[1] + (e.clientY - rect.top) * (curVb[3] / rect.height) + setCurVb(zoomAtPoint(curVb, zoomFactor, px, py)) } const onDblClick = () => { const init = wrap.__mermaidInitViewBox - if (!init) return - wrap.__mermaidCurViewBox = init.slice() - setSvgViewBox(svg, init) + if (init) setCurVb(init) } - svg.addEventListener('pointerdown', onPointerDown) - svg.addEventListener('pointermove', onPointerMove) - svg.addEventListener('pointerup', onPointerUpOrCancel) - svg.addEventListener('pointercancel', onPointerUpOrCancel) - svg.addEventListener('wheel', onWheel, { passive: false }) - svg.addEventListener('dblclick', onDblClick) + svg.addEventListener('pointerdown', onPointerDown, { signal: ac.signal }) + svg.addEventListener('pointermove', onPointerMove, { signal: ac.signal }) + svg.addEventListener('pointerup', onPointerUpOrCancel, { signal: ac.signal }) + svg.addEventListener('pointercancel', onPointerUpOrCancel, { signal: ac.signal }) + svg.addEventListener('wheel', onWheel, { passive: false, signal: ac.signal }) + svg.addEventListener('dblclick', onDblClick, { signal: ac.signal }) } const runMermaid = ele => { window.loadMermaid = true const theme = document.documentElement.getAttribute('data-theme') === 'dark' ? '!{theme.mermaid.theme.dark}' : '!{theme.mermaid.theme.light}' - ele.forEach((item, index) => { const mermaidSrc = item.firstElementChild - - // Clear old render (themeChange/pjax will rerun) + // Clean up event listeners before removing old SVG + if (item.__mermaidAbortController) { + item.__mermaidAbortController.abort() + } const oldSvg = item.querySelector('svg') if (oldSvg) oldSvg.remove() - item.__mermaidGestureBound = false - - const config = mermaidSrc.dataset.config ? JSON.parse(mermaidSrc.dataset.config) : {} + let config = {} + try { + config = mermaidSrc.dataset.config ? JSON.parse(mermaidSrc.dataset.config) : {} + } catch (e) { + console.warn('[mermaid] failed to parse dataset.config:', e) + } if (!config.theme) { config.theme = theme } @@ -278,7 +301,6 @@ script. const mermaidID = `mermaid-${index}` const mermaidDefinition = mermaidThemeConfig + mermaidSrc.textContent - const renderFn = mermaid.render(mermaidID, mermaidDefinition) const renderMermaid = svg => { mermaidSrc.insertAdjacentHTML('afterend', svg) if (!{theme.mermaid.zoom_pan}) initMermaidGestures(item) @@ -286,9 +308,25 @@ script. if (!{theme.mermaid.open_in_new_tab}) attachMermaidViewerButton(item) } + const handleError = err => { + console.error(`[mermaid] render failed for block #${index}:`, err) + const errorEl = document.createElement('div') + errorEl.className = 'mermaid-error' + errorEl.textContent = `Mermaid render error: ${err.message || err}` + mermaidSrc.insertAdjacentElement('afterend', errorEl) + } - // mermaid v9 and v10 compatibility - typeof renderFn === 'string' ? renderMermaid(renderFn) : renderFn.then(({ svg }) => renderMermaid(svg)) + try { + const renderFn = mermaid.render(mermaidID, mermaidDefinition) + // mermaid v9 and v10 compatibility + if (typeof renderFn === 'string') { + renderMermaid(renderFn) + } else { + renderFn.then(({ svg }) => renderMermaid(svg)).catch(handleError) + } + } catch (err) { + handleError(err) + } }) } diff --git a/layout/includes/third-party/pjax.pug b/layout/includes/third-party/pjax.pug index 9c97c19..75d1964 100644 --- a/layout/includes/third-party/pjax.pug +++ b/layout/includes/third-party/pjax.pug @@ -53,8 +53,9 @@ script. document.addEventListener('pjax:complete', () => { btf.removeGlobalFnEvent('pjaxCompleteOnce') document.querySelectorAll('script[data-pjax]').forEach(item => { + if (!item.parentNode) return const newScript = document.createElement('script') - const content = item.text || item.textContent || item.innerHTML || "" + const content = item.text || item.textContent || '' Array.from(item.attributes).forEach(attr => newScript.setAttribute(attr.name, attr.value)) newScript.appendChild(document.createTextNode(content)) item.parentNode.replaceChild(newScript, item) @@ -68,6 +69,8 @@ script. !{theme.error_404 && theme.error_404.enable} ? pjax.loadUrl('!{url_for("/404.html")}') : window.location.href = e.request.responseURL + } else { + window.location.href = e.request.responseURL } }) }) \ No newline at end of file diff --git a/scripts/filters/post_lazyload.js b/scripts/filters/post_lazyload.js index 2755cd3..eec040a 100644 --- a/scripts/filters/post_lazyload.js +++ b/scripts/filters/post_lazyload.js @@ -21,7 +21,7 @@ const lazyload = htmlContent => { // Handle src attributes with double quotes, single quotes, or no quotes (unified approach) // Matches: src="..." or src='...' or src=... (e.g., after minification by hexo-minify) return htmlContent.replace(/(]*?\bdata-lazy-src=)(?:\s[^>]*?)?\ssrc=)(?:"([^"]*)"|'([^']*)'|([^\s>]+))(?![^<]*<\/script>)/gi, (match, prefix, srcDoubleQuote, srcSingleQuote, srcNoQuote) => { - const src = srcDoubleQuote || srcSingleQuote || srcNoQuote + const src = srcDoubleQuote ?? srcSingleQuote ?? srcNoQuote return `${prefix}"${bg}" data-lazy-src="${src}"` }) } diff --git a/scripts/filters/random_cover.js b/scripts/filters/random_cover.js index d814c35..73e4278 100644 --- a/scripts/filters/random_cover.js +++ b/scripts/filters/random_cover.js @@ -6,33 +6,46 @@ hexo.extend.generator.register('post', locals => { const imgTestReg = /\.(png|jpe?g|gif|svg|webp|avif)(\?.*)?$/i + const remoteImgReg = /^(?:https?:)?\/\//i + const dataImgReg = /^data:image\//i + const { post_asset_folder: postAssetFolder } = hexo.config const { cover: { default_cover: defaultCover } } = hexo.theme.config + const isImage = value => { + return typeof value === 'string' && + (remoteImgReg.test(value) || dataImgReg.test(value) || imgTestReg.test(value)) + } + function * createCoverGenerator () { - if (!defaultCover) { + if (!defaultCover || (Array.isArray(defaultCover) && defaultCover.length === 0)) { while (true) yield false } + if (!Array.isArray(defaultCover)) { while (true) yield defaultCover } - const coverCount = defaultCover.length - if (coverCount === 1) { + if (defaultCover.length === 1) { while (true) yield defaultCover[0] } + const coverCount = defaultCover.length const maxHistory = Math.min(3, coverCount - 1) const history = [] while (true) { let index + do { index = Math.floor(Math.random() * coverCount) } while (history.includes(index)) history.push(index) - if (history.length > maxHistory) history.shift() + + if (history.length > maxHistory) { + history.shift() + } yield defaultCover[index] } @@ -40,32 +53,31 @@ hexo.extend.generator.register('post', locals => { const coverGenerator = createCoverGenerator() + const resolvePostAsset = (value, postPath) => { + if ( + !postAssetFolder || + typeof value !== 'string' || + value.includes('/') || + !imgTestReg.test(value) + ) { + return value + } + + return `${postPath}${value}` + } + const handleImg = data => { - let { cover: coverVal, top_img: topImg, pagination_cover: paginationCover } = data + data.top_img = resolvePostAsset(data.top_img, data.path) + data.cover = resolvePostAsset(data.cover, data.path) + data.pagination_cover = resolvePostAsset(data.pagination_cover, data.path) - // Add path to top_img and cover if post_asset_folder is enabled - if (postAssetFolder) { - if (topImg && topImg.indexOf('/') === -1 && imgTestReg.test(topImg)) { - data.top_img = `${data.path}${topImg}` - } - if (coverVal && coverVal.indexOf('/') === -1 && imgTestReg.test(coverVal)) { - data.cover = `${data.path}${coverVal}` - } - if (paginationCover && paginationCover.indexOf('/') === -1 && imgTestReg.test(paginationCover)) { - data.pagination_cover = `${data.path}${paginationCover}` - } + if (data.cover === false) return data + + if (!data.cover) { + data.cover = coverGenerator.next().value } - if (coverVal === false) return data - - // If cover is not set, use random cover - if (!coverVal) { - const randomCover = coverGenerator.next().value - data.cover = randomCover - coverVal = randomCover - } - - if (coverVal && (coverVal.indexOf('//') !== -1 || imgTestReg.test(coverVal))) { + if (isImage(data.cover)) { data.cover_type = 'img' } @@ -75,16 +87,23 @@ hexo.extend.generator.register('post', locals => { const posts = locals.posts.sort('date').toArray() const { length } = posts - return posts.map((post, i) => { - if (i) post.prev = posts[i - 1] - if (i < length - 1) post.next = posts[i + 1] + return posts.map((post, index) => { + const data = post - post.__post = true + if (index > 0) { + data.prev = posts[index - 1] + } + + if (index < length - 1) { + data.next = posts[index + 1] + } + + data.__post = true return { - data: handleImg(post), + data: handleImg(data), layout: 'post', - path: post.path + path: data.path } }) }) diff --git a/scripts/helpers/inject_head_js.js b/scripts/helpers/inject_head_js.js index 515e996..1f3ccb9 100644 --- a/scripts/helpers/inject_head_js.js +++ b/scripts/helpers/inject_head_js.js @@ -11,46 +11,87 @@ hexo.extend.helper.register('inject_head_js', function () { const createCustomJs = () => ` const saveToLocal = { set: (key, value, ttl) => { - if (!ttl) return - const expiry = Date.now() + ttl * 86400000 - localStorage.setItem(key, JSON.stringify({ value, expiry })) + const data = { value } + + if (ttl != null) { + data.expiry = Date.now() + ttl * 86400000 + } + + localStorage.setItem(key, JSON.stringify(data)) }, get: key => { const itemStr = localStorage.getItem(key) - if (!itemStr) return undefined - const { value, expiry } = JSON.parse(itemStr) - if (Date.now() > expiry) { + if (!itemStr) return + + try { + const data = JSON.parse(itemStr) + + if (data.expiry && Date.now() > data.expiry) { + localStorage.removeItem(key) + return + } + + return data.value + } catch { localStorage.removeItem(key) - return undefined } - return value } } + const scriptCache = new Map() + const cssCache = new Map() window.btf = { saveToLocal, - getScript: (url, attr = {}) => new Promise((resolve, reject) => { - const script = document.createElement('script') - script.src = url - script.async = true - Object.entries(attr).forEach(([key, val]) => script.setAttribute(key, val)) - script.onload = script.onreadystatechange = () => { - if (!script.readyState || /loaded|complete/.test(script.readyState)) resolve() + getScript: (url, attr = {}) => { + if (scriptCache.has(url)) { + return scriptCache.get(url) } - script.onerror = reject - document.head.appendChild(script) - }), - getCSS: (url, id) => new Promise((resolve, reject) => { - const link = document.createElement('link') - link.rel = 'stylesheet' - link.href = url - if (id) link.id = id - link.onload = link.onreadystatechange = () => { - if (!link.readyState || /loaded|complete/.test(link.readyState)) resolve() + + const promise = new Promise((resolve, reject) => { + const script = document.createElement('script') + + script.src = url + script.async = true + + for (const key in attr) { + script.setAttribute(key, attr[key]) + } + + script.onload = resolve + script.onerror = reject + + document.head.appendChild(script) + }) + + scriptCache.set(url, promise) + + return promise + }, + getCSS: (url, id) => { + if (cssCache.has(url)) { + return cssCache.get(url) } - link.onerror = reject - document.head.appendChild(link) - }), + + const promise = new Promise((resolve, reject) => { + const link = document.createElement('link') + + link.rel = 'stylesheet' + link.href = url + + if (id) { + link.id = id + } + + link.onload = resolve + link.onerror = reject + + document.head.appendChild(link) + }) + + cssCache.set(url, promise) + + return promise + }, addGlobalFn: (key, fn, name = false, parent = window) => { if (!${pjax.enable} && key.startsWith('pjax')) return const globalFn = parent.globalFn || {} @@ -65,16 +106,17 @@ hexo.extend.helper.register('inject_head_js', function () { if (!darkmode.enable) return '' let darkmodeJs = ` + const metaThemeColor = document.querySelector('meta[name="theme-color"]') const activateDarkMode = () => { - document.documentElement.setAttribute('data-theme', 'dark') - if (document.querySelector('meta[name="theme-color"]') !== null) { - document.querySelector('meta[name="theme-color"]').setAttribute('content', '${themeColorDark}') + document.documentElement.dataset.theme = 'dark' + if (metaThemeColor !== null) { + metaThemeColor.setAttribute('content', '${themeColorDark}') } } const activateLightMode = () => { - document.documentElement.setAttribute('data-theme', 'light') - if (document.querySelector('meta[name="theme-color"]') !== null) { - document.querySelector('meta[name="theme-color"]').setAttribute('content', '${themeColorLight}') + document.documentElement.dataset.theme = 'light' + if (metaThemeColor !== null) { + metaThemeColor.setAttribute('content', '${themeColorLight}') } } @@ -95,10 +137,15 @@ hexo.extend.helper.register('inject_head_js', function () { else if (mediaQueryDark.matches) activateDarkMode() else { const hour = new Date().getHours() - const isNight = hour <= ${start} || hour >= ${end} + const start = ${start} + const end = ${end} + const isNight = + start < end + ? hour < start || hour >= end + : hour >= start || hour < end isNight ? activateDarkMode() : activateLightMode() } - mediaQueryDark.addEventListener('change', () => { + mediaQueryDark.addEventListener('change', e => { if (saveToLocal.get('theme') === undefined) { e.matches ? activateDarkMode() : activateLightMode() } @@ -111,7 +158,12 @@ hexo.extend.helper.register('inject_head_js', function () { case 2: darkmodeJs += ` const hour = new Date().getHours() - const isNight = hour <= ${start} || hour >= ${end} + const start = ${start} + const end = ${end} + const isNight = + start < end + ? hour < start || hour >= end + : hour >= start || hour < end if (theme === undefined) isNight ? activateDarkMode() : activateLightMode() else theme === 'light' ? activateLightMode() : activateDarkMode() ` diff --git a/source/js/main.js b/source/js/main.js index ac2112e..539e747 100644 --- a/source/js/main.js +++ b/source/js/main.js @@ -2,18 +2,25 @@ document.addEventListener('DOMContentLoaded', () => { let headerContentWidth, $nav let mobileSidebarOpen = false + // rightsideScrollPercent + let goUpElement = null + let scrollPercentElement = null + const adjustMenu = init => { - const getAllWidth = ele => Array.from(ele).reduce((width, i) => width + i.offsetWidth, 0) + let hideMenuIndex = false if (init) { - const blogInfoWidth = getAllWidth(document.querySelector('#blog-info > a').children) - const menusWidth = getAllWidth(document.getElementById('menus').children) + const blogInfoWidth = Array.from(document.querySelector('#blog-info > a').children).reduce((w, i) => w + i.offsetWidth, 0) + const menusWidth = Array.from(document.getElementById('menus').children).reduce((w, i) => w + i.offsetWidth, 0) headerContentWidth = blogInfoWidth + menusWidth $nav = document.getElementById('nav') } - const hideMenuIndex = window.innerWidth <= 768 || headerContentWidth > $nav.offsetWidth - 120 - $nav.classList.toggle('hide-menu', hideMenuIndex) + hideMenuIndex = window.innerWidth <= 768 || headerContentWidth > $nav.offsetWidth - 120 + + requestAnimationFrame(() => { + $nav.classList.toggle('hide-menu', hideMenuIndex) + }) } // 初始化header @@ -54,7 +61,7 @@ document.addEventListener('DOMContentLoaded', () => { * 代碼 * 只適用於Hexo默認的代碼渲染 */ - const addHighlightTool = () => { + const addHighlightTool = $article => { const highLight = GLOBAL_CONFIG.highlight if (!highLight) return @@ -64,8 +71,8 @@ document.addEventListener('DOMContentLoaded', () => { const isNotHighlightJs = plugin !== 'highlight.js' const isPrismjs = plugin === 'prismjs' const $figureHighlight = isNotHighlightJs - ? Array.from(document.querySelectorAll('code[class*="language-"]')).map(code => code.parentElement) - : document.querySelectorAll('figure.highlight') + ? Array.from($article.querySelectorAll('code[class*="language-"]')).map(code => code.parentElement) + : $article.querySelectorAll('figure.highlight') if (!((isShowTool || highlightHeightLimit) && $figureHighlight.length)) return @@ -167,33 +174,23 @@ document.addEventListener('DOMContentLoaded', () => { // 獲取隱藏狀態下元素的真實高度 const getActualHeight = item => { if (item.offsetHeight > 0) return item.offsetHeight - const hiddenElements = new Map() - const fix = () => { - let current = item - while (current !== document.body && current != null) { - if (window.getComputedStyle(current).display === 'none') { - hiddenElements.set(current, current.getAttribute('style') || '') - } - current = current.parentNode - } + const clone = item.cloneNode(true) - const style = 'visibility: hidden !important; display: block !important;' - hiddenElements.forEach((originalStyle, elem) => { - elem.setAttribute('style', originalStyle ? originalStyle + ';' + style : style) - }) - } + clone.style.cssText = ` + position: absolute !important; + visibility: hidden !important; + display: block !important; + left: 0 !important; + top: 0 !important; + pointer-events: none !important; + z-index: -1 !important; + margin: 0 !important; + ` - const restore = () => { - hiddenElements.forEach((originalStyle, elem) => { - if (originalStyle === '') elem.removeAttribute('style') - else elem.setAttribute('style', originalStyle) - }) - } - - fix() - const height = item.offsetHeight - restore() + item.parentNode.insertBefore(clone, item) + const height = clone.offsetHeight + clone.remove() return height } @@ -244,9 +241,9 @@ document.addEventListener('DOMContentLoaded', () => { /** * PhotoFigcaption */ - const addPhotoFigcaption = () => { + const addPhotoFigcaption = $article => { if (!GLOBAL_CONFIG.isPhotoFigcaption) return - document.querySelectorAll('#article-container img').forEach(item => { + $article.querySelectorAll('img').forEach(item => { const altValue = item.title || item.alt if (!altValue) return const ele = document.createElement('div') @@ -259,8 +256,8 @@ document.addEventListener('DOMContentLoaded', () => { /** * Lightbox */ - const runLightbox = () => { - btf.loadLightbox(document.querySelectorAll('#article-container img:not(.no-lightbox)')) + const runLightbox = $article => { + btf.loadLightbox($article.querySelectorAll('img:not(.no-lightbox)')) } /** @@ -424,11 +421,11 @@ document.addEventListener('DOMContentLoaded', () => { */ const rightsideScrollPercent = currentTop => { const scrollPercent = btf.getScrollPercent(currentTop, document.body) - const goUpElement = document.getElementById('go-up') + if (!goUpElement || !scrollPercentElement) return if (scrollPercent < 95) { goUpElement.classList.add('show-percent') - goUpElement.querySelector('.scroll-percent').textContent = scrollPercent + scrollPercentElement.textContent = scrollPercent } else { goUpElement.classList.remove('show-percent') } @@ -465,7 +462,7 @@ document.addEventListener('DOMContentLoaded', () => { } let flag = '' - const scrollTask = btf.throttle(() => { + const scrollTask = btf.rafThrottle(() => { const currentTop = window.scrollY || document.documentElement.scrollTop const isDown = scrollDirection(currentTop) if (currentTop > 56) { @@ -497,18 +494,17 @@ document.addEventListener('DOMContentLoaded', () => { isShowPercent && rightsideScrollPercent(currentTop) checkDocumentHeight() - }, 300) + }) btf.addEventListenerPjax(window, 'scroll', scrollTask, { passive: true }) } /** - * toc,anchor + * toc, anchor */ - const scrollFnToDo = () => { + const scrollFnToDo = $article => { const isToc = GLOBAL_CONFIG_SITE.isToc const isAnchor = GLOBAL_CONFIG.isAnchor - const $article = document.getElementById('article-container') if (!($article && (isToc || isAnchor))) return @@ -521,7 +517,6 @@ document.addEventListener('DOMContentLoaded', () => { $tocPercentage = $cardTocLayout.querySelector('.toc-percentage') isExpand = $cardToc.classList.contains('is-expand') - // toc元素點擊 const tocItemClickFn = e => { const target = e.target.closest('.toc-link') if (!target) return @@ -548,86 +543,90 @@ document.addEventListener('DOMContentLoaded', () => { } } - // 處理 hexo-blog-encrypt 事件 $cardToc.style.display = 'block' } - // find head position & add active class const $articleList = $article.querySelectorAll('h1,h2,h3,h4,h5,h6') - let detectItem = '' + if (!$articleList.length) return - // Optimization: Cache header positions - let headerList = [] - const updateHeaderPositions = () => { - headerList = Array.from($articleList).map(ele => ({ - ele, - top: btf.getEleTop(ele), - id: ele.id - })) - } + let activeTocItem = null + let activeParentItems = [] - updateHeaderPositions() - const throttledUpdate = btf.throttle(updateHeaderPositions, 200) - btf.addEventListenerPjax(window, 'resize', throttledUpdate) + const updateTocUI = currentId => { + const encodedAnchor = currentId ? '#' + encodeURI(decodeURI(currentId)) : '' + if (isAnchor) btf.updateAnchor(encodedAnchor) - if ('ResizeObserver' in window) { - const observer = new ResizeObserver(throttledUpdate) - observer.observe($article) - btf.addGlobalFn('pjaxSendOnce', () => { observer.disconnect() }) - } + if (!isToc) return - const findHeadPosition = top => { - if (top === 0) return false + if (activeTocItem) activeTocItem.classList.remove('active') + activeParentItems.forEach(i => i.classList.remove('active')) + activeParentItems = [] - let currentId = '' - let currentIndex = '' - - for (let i = 0; i < headerList.length; i++) { - const item = headerList[i] - if (top > item.top - 80) { - currentId = item.id ? '#' + encodeURI(item.id) : '' - currentIndex = i - } else { - break - } + if (!currentId) { + activeTocItem = null + return } - if (detectItem === currentIndex) return + const targetLink = Array.from($tocLink).find(link => { + const href = link.getAttribute('href') + if (!href) return false + return decodeURI(href).replace('#', '') === decodeURI(currentId) + }) - if (isAnchor) btf.updateAnchor(currentId) + if (!targetLink) return - detectItem = currentIndex + targetLink.classList.add('active') + activeTocItem = targetLink + setTimeout(() => autoScrollToc(targetLink), 0) - if (isToc) { - $cardToc.querySelectorAll('.active').forEach(i => i.classList.remove('active')) - - if (currentId) { - const currentActive = $tocLink[currentIndex] - currentActive.classList.add('active') - - setTimeout(() => autoScrollToc(currentActive), 0) - - if (!isExpand) { - let parent = currentActive.parentNode - while (!parent.matches('.toc')) { - if (parent.matches('li')) parent.classList.add('active') - parent = parent.parentNode - } + if (!isExpand) { + let parent = targetLink.parentNode + while (!parent.matches('.toc')) { + if (parent.matches('li')) { + parent.classList.add('active') + activeParentItems.push(parent) } + parent = parent.parentNode } } } - // main of scroll - const tocScrollFn = btf.throttle(() => { + const observerOptions = { + root: null, + rootMargin: '-60px 0px -80% 0px', + threshold: 0 + } + + const observer = new IntersectionObserver(entries => { + entries.forEach(entry => { + if (entry.isIntersecting) { + updateTocUI(entry.target.id) + } + }) + }, observerOptions) + + $articleList.forEach(ele => observer.observe(ele)) + + const scrollHandler = btf.rafThrottle(() => { const currentTop = window.scrollY || document.documentElement.scrollTop + if (isToc && GLOBAL_CONFIG.percent.toc) { $tocPercentage.textContent = btf.getScrollPercent(currentTop, $article) } - findHeadPosition(currentTop) - }, 100) - btf.addEventListenerPjax(window, 'scroll', tocScrollFn, { passive: true }) + if (currentTop === 0) { + updateTocUI('') + } else if (currentTop + window.innerHeight >= document.documentElement.scrollHeight - 10) { + const lastHeader = $articleList[$articleList.length - 1] + updateTocUI(lastHeader.id) + } + }) + + btf.addEventListenerPjax(window, 'scroll', scrollHandler, { passive: true }) + + btf.addGlobalFn('pjaxSendOnce', () => { + observer.disconnect() + }) } const handleThemeChange = mode => { @@ -804,8 +803,8 @@ document.addEventListener('DOMContentLoaded', () => { /** * table overflow */ - const addTableWrap = () => { - const $table = document.querySelectorAll('#article-container table') + const addTableWrap = $article => { + const $table = $article.querySelectorAll('table') if (!$table.length) return $table.forEach(item => { @@ -815,52 +814,54 @@ document.addEventListener('DOMContentLoaded', () => { }) } - /** - * tag-hide - */ - const clickFnOfTagHide = () => { - const hideButtons = document.querySelectorAll('#article-container .hide-button') + const clickFnOfTagHide = $article => { + const hideButtons = $article.querySelectorAll('.hide-button') if (!hideButtons.length) return - hideButtons.forEach(item => item.addEventListener('click', e => { - const currentTarget = e.currentTarget - currentTarget.classList.add('open') - addJustifiedGallery(currentTarget.nextElementSibling.querySelectorAll('.gallery-container')) - }, { once: true })) + + const handleClickOfTagHide = e => { + const button = e.target.closest('.hide-button') + if (!button) return + button.classList.add('open') + addJustifiedGallery(button.nextElementSibling.querySelectorAll('.gallery-container')) + } + + btf.addEventListenerPjax($article, 'click', handleClickOfTagHide) } - const tabsFn = () => { - const navTabsElements = document.querySelectorAll('#article-container .tabs') - if (!navTabsElements.length) return + const tabsFn = $article => { + if (!$article.querySelector('.tabs')) return const setActiveClass = (elements, activeIndex) => { - elements.forEach((el, index) => { - el.classList.toggle('active', index === activeIndex) - }) + elements.forEach((el, index) => el.classList.toggle('active', index === activeIndex)) } - const handleNavClick = e => { - const target = e.target.closest('button') - if (!target || target.classList.contains('active')) return + const handleClick = e => { + const tabsRoot = e.target.closest('.tabs') + if (!tabsRoot) return - const navItems = [...e.currentTarget.children] - const tabContents = [...e.currentTarget.nextElementSibling.children] - const indexOfButton = navItems.indexOf(target) - setActiveClass(navItems, indexOfButton) - e.currentTarget.classList.remove('no-default') - setActiveClass(tabContents, indexOfButton) - addJustifiedGallery(tabContents[indexOfButton].querySelectorAll('.gallery-container'), true) - } + const navContainer = tabsRoot.firstElementChild + const toTopContainer = tabsRoot.lastElementChild - const handleToTopClick = tabElement => e => { - if (e.target.closest('button')) { - btf.scrollToDest(btf.getEleTop(tabElement), 300) + if (navContainer.contains(e.target)) { + const target = e.target.closest('button') + if (!target || target.classList.contains('active')) return + + const navItems = [...navContainer.children] + const tabContents = [...navContainer.nextElementSibling.children] + const indexOfButton = navItems.indexOf(target) + setActiveClass(navItems, indexOfButton) + navContainer.classList.remove('no-default') + setActiveClass(tabContents, indexOfButton) + addJustifiedGallery(tabContents[indexOfButton].querySelectorAll('.gallery-container'), true) + return + } + + if (toTopContainer.contains(e.target) && e.target.closest('button')) { + btf.scrollToDest(btf.getEleTop(tabsRoot), 300) } } - navTabsElements.forEach(tabElement => { - btf.addEventListenerPjax(tabElement.firstElementChild, 'click', handleNavClick) - btf.addEventListenerPjax(tabElement.lastElementChild, 'click', handleToTopClick(tabElement)) - }) + btf.addEventListenerPjax($article, 'click', handleClick) } const toggleCardCategory = () => { @@ -926,10 +927,13 @@ document.addEventListener('DOMContentLoaded', () => { } const unRefreshFn = () => { - window.addEventListener('resize', () => { + const resizeHandler = btf.rafThrottle(() => { adjustMenu(false) - mobileSidebarOpen && btf.isHidden(document.getElementById('toggle-menu')) && sidebarFn.close() + if (mobileSidebarOpen && btf.isHidden(document.getElementById('toggle-menu'))) { + sidebarFn.close() + } }) + window.addEventListener('resize', resizeHandler, { passive: true }) const menuMask = document.getElementById('menu-mask') menuMask && menuMask.addEventListener('click', () => { sidebarFn.close() }) @@ -947,18 +951,24 @@ document.addEventListener('DOMContentLoaded', () => { } const forPostFn = () => { - addHighlightTool() - addPhotoFigcaption() - addJustifiedGallery(document.querySelectorAll('#article-container .gallery-container')) - runLightbox() - scrollFnToDo() - addTableWrap() - clickFnOfTagHide() - tabsFn() + const $article = document.getElementById('article-container') + if (!$article) return + + addHighlightTool($article) + addPhotoFigcaption($article) + addJustifiedGallery($article.querySelectorAll('.gallery-container')) + runLightbox($article) + scrollFnToDo($article) + addTableWrap($article) + clickFnOfTagHide($article) + tabsFn($article) } const refreshFn = () => { initAdjust() + goUpElement = document.getElementById('go-up') + scrollPercentElement = goUpElement?.querySelector('.scroll-percent') + justifiedIndexPostUI() if (GLOBAL_CONFIG_SITE.pageType === 'post') { diff --git a/source/js/search/algolia.js b/source/js/search/algolia.js index 684e25f..24746af 100644 --- a/source/js/search/algolia.js +++ b/source/js/search/algolia.js @@ -1,562 +1,471 @@ -window.addEventListener('load', () => { - const { algolia } = GLOBAL_CONFIG - const { appId, apiKey, indexName, hitsPerPage = 5, languages } = algolia - - if (!appId || !apiKey || !indexName) { - return console.error('Algolia setting is invalid!') - } - - const $searchMask = document.getElementById('search-mask') - const $searchDialog = document.querySelector('#algolia-search .search-dialog') - - const animateElements = show => { - const action = show ? 'animateIn' : 'animateOut' - const maskAnimation = show ? 'to_show 0.5s' : 'to_hide 0.5s' - const dialogAnimation = show ? 'titleScale 0.5s' : 'search_close .5s' - btf[action]($searchMask, maskAnimation) - btf[action]($searchDialog, dialogAnimation) - } - - const fixSafariHeight = () => { - if (window.innerWidth < 768) { - $searchDialog.style.setProperty('--search-height', `${window.innerHeight}px`) - } - } - - const openSearch = () => { - btf.overflowPaddingR.add() - animateElements(true) - showLoading(false) - - setTimeout(() => { - const searchInput = document.querySelector('#algolia-search-input .ais-SearchBox-input') - if (searchInput) searchInput.focus() - }, 100) - - const handleEscape = event => { - if (event.code === 'Escape') { - closeSearch() - document.removeEventListener('keydown', handleEscape) - } - } - - document.addEventListener('keydown', handleEscape) - fixSafariHeight() - window.addEventListener('resize', fixSafariHeight) - } - - const closeSearch = () => { - btf.overflowPaddingR.remove() - animateElements(false) - window.removeEventListener('resize', fixSafariHeight) - } - - const searchClickFn = () => { - btf.addEventListenerPjax(document.querySelector('#search-button > .search'), 'click', openSearch) - } - - const searchFnOnce = () => { - $searchMask.addEventListener('click', closeSearch) - document.querySelector('#algolia-search .search-close-button').addEventListener('click', closeSearch) - } - - const cutContent = content => { - if (!content) return '' - - let contentStr = '' - if (typeof content === 'string') { - contentStr = content.trim() - } else if (typeof content === 'object') { - if (content.value !== undefined) { - contentStr = String(content.value).trim() - if (!contentStr) return '' - } else if (content.matchedWords || content.matchLevel || content.fullyHighlighted !== undefined) { - return '' - } else { - try { - contentStr = JSON.stringify(content).trim() - if (contentStr === '{}' || contentStr === '[]' || contentStr === '""') { - return '' - } - } catch (e) { - return '' - } - } - } else if (content.toString && typeof content.toString === 'function') { - contentStr = content.toString().trim() - if (contentStr === '[object Object]' || contentStr === '[object Array]') { - return '' - } - } else { - return '' - } - - const firstOccur = contentStr.indexOf('') - let start = firstOccur - 30 - let end = firstOccur + 120 - let pre = '' - let post = '' - - if (start <= 0) { - start = 0 - end = 140 - } else { - pre = '...' - } - - if (end > contentStr.length) { - end = contentStr.length - } else { - post = '...' - } - - // Ensure we don't cut off HTML tags in the middle - let substr = contentStr.substring(start, end) - - // Handle tag completeness - // Check for incomplete opening tags at the beginning - const firstCloseBracket = substr.indexOf('>') - const firstOpenBracket = substr.indexOf('<') - - // If there's a closing bracket but no opening bracket before it, we've cut a tag - if (firstCloseBracket !== -1 && (firstOpenBracket === -1 || firstCloseBracket < firstOpenBracket)) { - substr = substr.substring(firstCloseBracket + 1) - } - - // Check for incomplete closing tags at the end - const lastOpenBracket = substr.lastIndexOf('<') - const lastCloseBracket = substr.lastIndexOf('>') - - // If there's an opening bracket after the last closing bracket, we've cut a tag - if (lastOpenBracket !== -1 && lastOpenBracket > lastCloseBracket) { - substr = substr.substring(0, lastOpenBracket) - } - - // Balance tags in the substring - const tagStack = [] - let balancedStr = '' - let i = 0 - - while (i < substr.length) { - if (substr[i] === '<') { - // Check if it's a closing tag - if (substr[i + 1] === '/') { - const closeTagEnd = substr.indexOf('>', i) - if (closeTagEnd !== -1) { - const closeTagName = substr.substring(i + 2, closeTagEnd) - // Remove matching opening tag from stack - for (let j = tagStack.length - 1; j >= 0; j--) { - if (tagStack[j] === closeTagName) { - tagStack.splice(j, 1) - break - } - } - balancedStr += substr.substring(i, closeTagEnd + 1) - i = closeTagEnd + 1 - continue - } - } else if (substr.substr(i, 2) === '', i) !== -1 && substr.indexOf('/>', i) < substr.indexOf('>', i))) { - const tagEnd = substr.indexOf('>', i) - if (tagEnd !== -1) { - balancedStr += substr.substring(i, tagEnd + 1) - i = tagEnd + 1 - continue - } - } else { - const tagEnd = substr.indexOf('>', i) - if (tagEnd !== -1) { - const tagName = substr.substring(i + 1, (substr.indexOf(' ', i) > -1 && substr.indexOf(' ', i) < tagEnd) - ? substr.indexOf(' ', i) - : tagEnd).split(/\s/)[0] - tagStack.push(tagName) - balancedStr += substr.substring(i, tagEnd + 1) - i = tagEnd + 1 - continue - } - } - } - balancedStr += substr[i] - i++ - } - - // Close any unclosed tags - while (tagStack.length > 0) { - const tagName = tagStack.pop() - balancedStr += `` - } - - // If we removed content from the beginning, add prefix - if (start > 0 || pre) { - const actualFirstOpenBracket = contentStr.indexOf('<', start > 0 ? start - 30 : 0) - const actualFirstMark = contentStr.indexOf('', start > 0 ? start - 30 : 0) - - if (actualFirstOpenBracket !== -1 && - (actualFirstMark === -1 || actualFirstOpenBracket < actualFirstMark)) { - pre = '...' - } - } - - substr = balancedStr - return `${pre}${substr}${post}` - } - - // Helper function to handle Algolia highlight results - const extractHighlightValue = highlightObj => { - if (!highlightObj) return '' - - if (typeof highlightObj === 'string') { - return highlightObj.trim() - } - - if (typeof highlightObj === 'object' && highlightObj.value !== undefined) { - return String(highlightObj.value).trim() - } - - return '' - } - - // Initialize Algolia client - let searchClient - - if (window['algoliasearch/lite'] && typeof window['algoliasearch/lite'].liteClient === 'function') { - searchClient = window['algoliasearch/lite'].liteClient(appId, apiKey) - } else if (typeof window.algoliasearch === 'function') { - searchClient = window.algoliasearch(appId, apiKey) - } else { - return console.error('Algolia search client not found!') - } - - if (!searchClient) { - return console.error('Failed to initialize Algolia search client') - } - - // Search state - let currentQuery = '' - - // Show loading state - const showLoading = show => { - const loadingIndicator = document.getElementById('loading-status') - if (loadingIndicator) { - loadingIndicator.hidden = !show - } - } - - // Cache frequently used elements - const elements = { - get searchInput () { return document.querySelector('#algolia-search-input .ais-SearchBox-input') }, - get hits () { return document.getElementById('algolia-hits') }, - get hitsEmpty () { return document.getElementById('algolia-hits-empty') }, - get hitsList () { return document.querySelector('#algolia-hits .ais-Hits-list') }, - get hitsWrapper () { return document.querySelector('#algolia-hits .ais-Hits') }, - get pagination () { return document.getElementById('algolia-pagination') }, - get paginationList () { return document.querySelector('#algolia-pagination .ais-Pagination-list') }, - get stats () { return document.querySelector('#algolia-info .ais-Stats-text') }, - } - - // Show/hide search results area - const toggleResultsVisibility = hasResults => { - elements.pagination.style.display = hasResults ? '' : 'none' - elements.stats.style.display = hasResults ? '' : 'none' - } - - // Render search results - const renderHits = (hits, query, page = 0) => { - if (hits.length === 0 && query) { - elements.hitsEmpty.textContent = languages.hits_empty.replace(/\$\{query}/, query) - elements.hitsEmpty.style.display = '' - elements.hitsWrapper.style.display = 'none' - elements.stats.style.display = 'none' - return - } - - elements.hitsEmpty.style.display = 'none' - - const hitsHTML = hits.map((hit, index) => { - const itemNumber = page * hitsPerPage + index + 1 - const link = hit.permalink || (GLOBAL_CONFIG.root + hit.path) - const result = hit._highlightResult || hit - - // Content extraction - let content = '' - try { - if (result.contentStripTruncate) { - content = cutContent(result.contentStripTruncate) - } else if (result.contentStrip) { - content = cutContent(result.contentStrip) - } else if (result.content) { - content = cutContent(result.content) - } else if (hit.contentStripTruncate) { - content = cutContent(hit.contentStripTruncate) - } else if (hit.contentStrip) { - content = cutContent(hit.contentStrip) - } else if (hit.content) { - content = cutContent(hit.content) - } - } catch (error) { - content = '' - } - - // Title handling - let title = 'no-title' - try { - if (result.title) { - title = extractHighlightValue(result.title) || 'no-title' - } else if (hit.title) { - title = extractHighlightValue(hit.title) || 'no-title' - } - - if (!title || title === 'no-title') { - if (typeof hit.title === 'string' && hit.title.trim()) { - title = hit.title.trim() - } else if (hit.title && typeof hit.title === 'object' && hit.title.value) { - title = String(hit.title.value).trim() || 'no-title' - } else { - title = 'no-title' - } - } - } catch (error) { - title = 'no-title' - } - - return ` -
  • - - ${title} - ${content ? `
    ${content}
    ` : ''} -
    -
  • ` - }).join('') - - elements.hitsList.innerHTML = hitsHTML - elements.hitsWrapper.style.display = query ? '' : 'none' - - if (hits.length > 0) { - elements.stats.style.display = '' - } - } - - // Render pagination - const renderPagination = (page, nbPages) => { - if (nbPages <= 1) { - elements.pagination.style.display = 'none' - elements.paginationList.innerHTML = '' - return - } - - elements.pagination.style.display = 'block' - - const isFirstPage = page === 0 - const isLastPage = page === nbPages - 1 - - // Responsive page display - const isMobile = window.innerWidth < 768 - const maxVisiblePages = isMobile ? 3 : 5 - let startPage = Math.max(0, page - Math.floor(maxVisiblePages / 2)) - const endPage = Math.min(nbPages - 1, startPage + maxVisiblePages - 1) - - // Adjust starting page to maintain max visible pages - if (endPage - startPage + 1 < maxVisiblePages) { - startPage = Math.max(0, endPage - maxVisiblePages + 1) - } - - let pagesHTML = '' - - // Only add ellipsis and first page when there are many pages - if (nbPages > maxVisiblePages && startPage > 0) { - pagesHTML += ` -
  • - 1 -
  • ` - if (startPage > 1) { - pagesHTML += ` -
  • - ... -
  • ` - } - } - - // Add middle page numbers - for (let i = startPage; i <= endPage; i++) { - const isSelected = i === page - if (isSelected) { - pagesHTML += ` -
  • - ${i + 1} -
  • ` - } else { - pagesHTML += ` -
  • - ${i + 1} -
  • ` - } - } - - // Only add ellipsis and last page when there are many pages - if (nbPages > maxVisiblePages && endPage < nbPages - 1) { - if (endPage < nbPages - 2) { - pagesHTML += ` -
  • - ... -
  • ` - } - pagesHTML += ` -
  • - ${nbPages} -
  • ` - } - - if (nbPages > 1) { - elements.paginationList.innerHTML = ` -
  • - ${isFirstPage - ? '' - : `` - } -
  • - ${pagesHTML} -
  • - ${isLastPage - ? '' - : `` - } -
  • ` - elements.pagination.style.display = currentQuery ? '' : 'none' - } else { - elements.pagination.style.display = 'none' - } - } - - // Render statistics - const renderStats = (nbHits, processingTimeMS, query) => { - if (query) { - const stats = languages.hits_stats - .replace(/\$\{hits}/, nbHits) - .replace(/\$\{time}/, processingTimeMS) - elements.stats.innerHTML = `
    ${stats}` - elements.stats.style.display = '' - } else { - elements.stats.style.display = 'none' - } - } - - // Perform search - const performSearch = async (query, page = 0) => { - if (!query.trim()) { - currentQuery = '' - renderHits([], '', 0) - renderPagination(0, 0) - renderStats(0, 0, '') - toggleResultsVisibility(false) - return - } - - showLoading(true) - currentQuery = query - - try { - let result - - if (searchClient && typeof searchClient.search === 'function') { - // v5 multi-index search - const searchResult = await searchClient.search([{ - indexName, - query, - params: { - page, - hitsPerPage, - highlightPreTag: '', - highlightPostTag: '', - attributesToHighlight: ['title', 'content', 'contentStrip', 'contentStripTruncate'] - } - }]) - result = searchResult.results[0] - } else if (searchClient && typeof searchClient.initIndex === 'function') { - // v4 single-index search - const index = searchClient.initIndex(indexName) - result = await index.search(query, { - page, - hitsPerPage, - highlightPreTag: '', - highlightPostTag: '', - attributesToHighlight: ['title', 'content', 'contentStrip', 'contentStripTruncate'] - }) - } else { - throw new Error('Algolia: No compatible search method available') - } - - renderHits(result.hits || [], query, page) - - const actualNbPages = result.nbHits <= hitsPerPage ? 1 : (result.nbPages || 0) - renderPagination(page, actualNbPages) - renderStats(result.nbHits || 0, result.processingTimeMS || 0, query) - - const hasResults = result.hits && result.hits.length > 0 - toggleResultsVisibility(hasResults) - - // Refresh Pjax links - if (window.pjax) { - window.pjax.refresh(document.getElementById('algolia-hits')) - } - } catch (error) { - console.error('Algolia search error:', error) - renderHits([], query, page) - renderPagination(0, 0) - renderStats(0, 0, query) - } finally { - showLoading(false) - } - } - - // Debounced search - let searchTimeout - const debouncedSearch = (query, delay = 300) => { - clearTimeout(searchTimeout) - searchTimeout = setTimeout(() => performSearch(query), delay) - } - - // Initialize search box and events - const initializeSearch = () => { - showLoading(false) - - if (elements.searchInput) { - elements.searchInput.addEventListener('input', e => { - const query = e.target.value - debouncedSearch(query) - }) - } - - const searchForm = document.querySelector('#algolia-search-input .ais-SearchBox-form') - if (searchForm) { - searchForm.addEventListener('submit', e => { - e.preventDefault() - const query = elements.searchInput.value - performSearch(query) - }) - } - - // Pagination event delegation - elements.pagination.addEventListener('click', e => { - e.preventDefault() - const link = e.target.closest('a[data-page]') - if (link) { - const page = parseInt(link.dataset.page, 10) - if (!isNaN(page) && currentQuery) { - performSearch(currentQuery, page) - } - } - }) - - // Initial state - toggleResultsVisibility(false) - } - - // Initialize - initializeSearch() - searchClickFn() - searchFnOnce() - - window.addEventListener('pjax:complete', () => { - if (!btf.isHidden($searchMask)) closeSearch() - searchClickFn() - }) -}) +window.addEventListener('load', () => { + const { algolia } = GLOBAL_CONFIG + const { appId, apiKey, indexName, hitsPerPage = 5, languages } = algolia + + if (!appId || !apiKey || !indexName) { + return console.error('Algolia setting is invalid!') + } + + const CONTENT_FIELDS = ['contentStripTruncate', 'contentStrip', 'content'] + const HIGHLIGHT_PARAMS = { + highlightPreTag: '', + highlightPostTag: '', + attributesToHighlight: ['title', 'content', 'contentStrip', 'contentStripTruncate'] + } + + // Pre-compiled regex for tag balancing (reused across cutContent calls) + const TAG_REGEX = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*\/?>/g + + const $searchMask = document.getElementById('search-mask') + const $searchDialog = document.querySelector('#algolia-search .search-dialog') + const $loadingStatus = document.getElementById('loading-status') + const $hits = document.getElementById('algolia-hits') + const $hitsEmpty = document.getElementById('algolia-hits-empty') + const $hitsList = document.querySelector('#algolia-hits .ais-Hits-list') + const $hitsWrapper = document.querySelector('#algolia-hits .ais-Hits') + const $pagination = document.getElementById('algolia-pagination') + const $paginationList = document.querySelector('#algolia-pagination .ais-Pagination-list') + const $stats = document.querySelector('#algolia-info .ais-Stats-text') + const $searchInput = document.querySelector('#algolia-search-input .ais-SearchBox-input') + const $searchForm = document.querySelector('#algolia-search-input .ais-SearchBox-form') + + const animateElements = show => { + const action = show ? 'animateIn' : 'animateOut' + const maskAnimation = show ? 'to_show 0.5s' : 'to_hide 0.5s' + const dialogAnimation = show ? 'titleScale 0.5s' : 'search_close .5s' + btf[action]($searchMask, maskAnimation) + btf[action]($searchDialog, dialogAnimation) + } + + const fixSafariHeight = () => { + if (window.innerWidth < 768) { + $searchDialog.style.setProperty('--search-height', `${window.innerHeight}px`) + } + } + + // Debounced resize to avoid layout thrashing + let resizeTimer + const onResize = () => { + clearTimeout(resizeTimer) + resizeTimer = setTimeout(fixSafariHeight, 150) + } + + const handleEscape = event => { + if (event.code === 'Escape') { + closeSearch() + document.removeEventListener('keydown', handleEscape) + } + } + + const showLoading = show => { + if ($loadingStatus) $loadingStatus.hidden = !show + } + + const openSearch = () => { + btf.overflowPaddingR.add() + animateElements(true) + showLoading(false) + + setTimeout(() => { + if ($searchInput) $searchInput.focus() + }, 100) + + document.addEventListener('keydown', handleEscape) + fixSafariHeight() + window.addEventListener('resize', onResize) + } + + const closeSearch = () => { + btf.overflowPaddingR.remove() + animateElements(false) + document.removeEventListener('keydown', handleEscape) + window.removeEventListener('resize', onResize) + } + + const searchClickFn = () => { + btf.addEventListenerPjax(document.querySelector('#search-button > .search'), 'click', openSearch) + } + + const searchFnOnce = () => { + $searchMask.addEventListener('click', closeSearch) + document.querySelector('#algolia-search .search-close-button').addEventListener('click', closeSearch) + } + + const extractContentStr = content => { + if (!content) return '' + if (typeof content === 'string') return content.trim() + if (typeof content === 'object') { + if (content.value !== undefined) { + const str = String(content.value).trim() + return str || '' + } + if (content.matchedWords || content.matchLevel || content.fullyHighlighted !== undefined) return '' + try { + const str = JSON.stringify(content).trim() + return (str === '{}' || str === '[]' || str === '""') ? '' : str + } catch (e) { return '' } + } + if (content.toString && typeof content.toString === 'function') { + const str = content.toString().trim() + return (str === '[object Object]' || str === '[object Array]') ? '' : str + } + return '' + } + + const extractHighlightValue = highlightObj => { + if (!highlightObj) return '' + if (typeof highlightObj === 'string') return highlightObj.trim() + if (typeof highlightObj === 'object' && highlightObj.value !== undefined) { + return String(highlightObj.value).trim() + } + return '' + } + + const cutContent = content => { + const contentStr = extractContentStr(content) + if (!contentStr) return '' + + const firstOccur = contentStr.indexOf('') + let start = firstOccur - 30 + let end = firstOccur + 120 + let pre = '' + let post = '' + + if (start <= 0) { + start = 0 + end = 140 + } else { + pre = '...' + } + + if (end > contentStr.length) { + end = contentStr.length + } else { + post = '...' + } + + let substr = contentStr.substring(start, end) + + // Remove incomplete tags at boundaries + const firstCloseBracket = substr.indexOf('>') + const firstOpenBracket = substr.indexOf('<') + if (firstCloseBracket !== -1 && (firstOpenBracket === -1 || firstCloseBracket < firstOpenBracket)) { + substr = substr.substring(firstCloseBracket + 1) + } + + const lastOpenBracket = substr.lastIndexOf('<') + const lastCloseBracket = substr.lastIndexOf('>') + if (lastOpenBracket !== -1 && lastOpenBracket > lastCloseBracket) { + substr = substr.substring(0, lastOpenBracket) + } + + // Balance tags using regex + const tagStack = [] + let balancedStr = '' + let lastIndex = 0 + let match + + TAG_REGEX.lastIndex = 0 + while ((match = TAG_REGEX.exec(substr)) !== null) { + const fullTag = match[0] + const tagName = match[1] + const tagStart = match.index + + // Append text before this tag + balancedStr += substr.substring(lastIndex, tagStart) + + if (fullTag.startsWith('') && !fullTag.startsWith('= 0; i--) { + balancedStr += `` + } + + // Check if we cut a mark tag at the beginning + if (start > 0 || pre) { + const checkStart = Math.max(0, start - 30) + const actualFirstOpenBracket = contentStr.indexOf('<', checkStart) + const actualFirstMark = contentStr.indexOf('', checkStart) + if (actualFirstOpenBracket !== -1 && (actualFirstMark === -1 || actualFirstOpenBracket < actualFirstMark)) { + pre = '...' + } + } + + return `${pre}${balancedStr}${post}` + } + + let searchClient + + if (window['algoliasearch/lite'] && typeof window['algoliasearch/lite'].liteClient === 'function') { + searchClient = window['algoliasearch/lite'].liteClient(appId, apiKey) + } else if (typeof window.algoliasearch === 'function') { + searchClient = window.algoliasearch(appId, apiKey) + } else { + return console.error('Algolia search client not found!') + } + + if (!searchClient) { + return console.error('Failed to initialize Algolia search client') + } + + let currentQuery = '' + let searchRequestId = 0 // Race condition guard + + const toggleResultsVisibility = hasResults => { + $pagination.style.display = hasResults ? '' : 'none' + $stats.style.display = hasResults ? '' : 'none' + } + + const renderHits = (hits, query, page = 0) => { + if (hits.length === 0 && query) { + $hitsEmpty.textContent = languages.hits_empty.replace(/\$\{query}/, query) + $hitsEmpty.style.display = '' + $hitsWrapper.style.display = 'none' + $stats.style.display = 'none' + return + } + + $hitsEmpty.style.display = 'none' + + const hitsHTML = hits.map((hit, index) => { + const itemNumber = page * hitsPerPage + index + 1 + const link = hit.permalink || (GLOBAL_CONFIG.root + hit.path) + const result = hit._highlightResult || hit + + // Content extraction - try highlight result first, then raw hit + let content = '' + for (const field of CONTENT_FIELDS) { + if (result[field]) { content = cutContent(result[field]); break } + if (hit[field]) { content = cutContent(hit[field]); break } + } + + // Title handling - try highlight result first, then raw hit + let title = 'no-title' + const titleSource = result.title || hit.title + if (titleSource) { + title = extractHighlightValue(titleSource) || 'no-title' + } + if (title === 'no-title') { + if (typeof hit.title === 'string' && hit.title.trim()) { + title = hit.title.trim() + } else if (hit.title?.value) { + title = String(hit.title.value).trim() || 'no-title' + } + } + + return `
  • + + ${title} + ${content ? `
    ${content}
    ` : ''} +
    +
  • ` + }).join('') + + $hitsList.innerHTML = hitsHTML + $hitsWrapper.style.display = query ? '' : 'none' + + if (hits.length > 0) { + $stats.style.display = '' + } + } + + const renderPagination = (page, nbPages) => { + if (nbPages <= 1) { + $pagination.style.display = 'none' + $paginationList.innerHTML = '' + return + } + + const isFirstPage = page === 0 + const isLastPage = page === nbPages - 1 + + // Responsive page display + const isMobile = window.innerWidth < 768 + const maxVisiblePages = isMobile ? 3 : 5 + let startPage = Math.max(0, page - Math.floor(maxVisiblePages / 2)) + const endPage = Math.min(nbPages - 1, startPage + maxVisiblePages - 1) + + // Adjust starting page to maintain max visible pages + if (endPage - startPage + 1 < maxVisiblePages) { + startPage = Math.max(0, endPage - maxVisiblePages + 1) + } + + const parts = [] + + // Only add ellipsis and first page when there are many pages + if (nbPages > maxVisiblePages && startPage > 0) { + parts.push('
  • 1
  • ') + if (startPage > 1) { + parts.push('
  • ...
  • ') + } + } + + // Add middle page numbers + for (let i = startPage; i <= endPage; i++) { + if (i === page) { + parts.push(`
  • ${i + 1}
  • `) + } else { + parts.push(`
  • ${i + 1}
  • `) + } + } + + // Only add ellipsis and last page when there are many pages + if (nbPages > maxVisiblePages && endPage < nbPages - 1) { + if (endPage < nbPages - 2) { + parts.push('
  • ...
  • ') + } + parts.push(`
  • ${nbPages}
  • `) + } + + // Build prev/next links + const prevLink = isFirstPage + ? '' + : `` + const nextLink = isLastPage + ? '' + : `` + + $paginationList.innerHTML = `
  • ${prevLink}
  • ${parts.join('')}
  • ${nextLink}
  • ` + $pagination.style.display = currentQuery ? '' : 'none' + } + + const renderStats = (nbHits, processingTimeMS, query) => { + if (query) { + const stats = languages.hits_stats + .replace(/\$\{hits}/, nbHits) + .replace(/\$\{time}/, processingTimeMS) + $stats.innerHTML = `
    ${stats}` + $stats.style.display = '' + } else { + $stats.style.display = 'none' + } + } + + const performSearch = async (query, page = 0) => { + const trimmedQuery = query.trim() + + if (!trimmedQuery) { + currentQuery = '' + searchRequestId++ + renderHits([], '', 0) + renderPagination(0, 0) + renderStats(0, 0, '') + toggleResultsVisibility(false) + return + } + + showLoading(true) + currentQuery = trimmedQuery + const requestId = ++searchRequestId + + try { + let result + + if (searchClient && typeof searchClient.search === 'function') { + // v5 multi-index search + const searchResult = await searchClient.search([{ + indexName, + query: trimmedQuery, + params: { page, hitsPerPage, ...HIGHLIGHT_PARAMS } + }]) + result = searchResult.results[0] + } else if (searchClient && typeof searchClient.initIndex === 'function') { + // v4 single-index search + const index = searchClient.initIndex(indexName) + result = await index.search(trimmedQuery, { page, hitsPerPage, ...HIGHLIGHT_PARAMS }) + } else { + throw new Error('Algolia: No compatible search method available') + } + + // Discard stale results from superseded searches + if (requestId !== searchRequestId) return + + renderHits(result.hits || [], trimmedQuery, page) + + const actualNbPages = result.nbHits <= hitsPerPage ? 1 : (result.nbPages || 0) + renderPagination(page, actualNbPages) + renderStats(result.nbHits || 0, result.processingTimeMS || 0, trimmedQuery) + + const hasResults = result.hits && result.hits.length > 0 + toggleResultsVisibility(hasResults) + + // Refresh Pjax links + if (window.pjax) { + window.pjax.refresh($hits) + } + } catch (error) { + if (requestId !== searchRequestId) return + console.error('Algolia search error:', error) + renderHits([], trimmedQuery, page) + renderPagination(0, 0) + renderStats(0, 0, trimmedQuery) + } finally { + if (requestId === searchRequestId) { + showLoading(false) + } + } + } + + let searchTimeout + const debouncedSearch = (query, delay = 300) => { + clearTimeout(searchTimeout) + // Empty query: clear results immediately without debounce delay + if (!query.trim()) { + performSearch(query) + return + } + searchTimeout = setTimeout(() => performSearch(query), delay) + } + + const initializeSearch = () => { + showLoading(false) + + if ($searchInput) { + $searchInput.addEventListener('input', e => { + debouncedSearch(e.target.value) + }) + } + + if ($searchForm) { + $searchForm.addEventListener('submit', e => { + e.preventDefault() + performSearch($searchInput ? $searchInput.value : '') + }) + } + + // Pagination event delegation + $pagination.addEventListener('click', e => { + e.preventDefault() + const link = e.target.closest('a[data-page]') + if (link) { + const page = parseInt(link.dataset.page, 10) + if (!isNaN(page) && currentQuery) { + performSearch(currentQuery, page) + } + } + }) + + // Initial state + toggleResultsVisibility(false) + } + + initializeSearch() + searchClickFn() + searchFnOnce() + + window.addEventListener('pjax:complete', () => { + if (!btf.isHidden($searchMask)) closeSearch() + searchClickFn() + }) +}) diff --git a/source/js/search/local-search.js b/source/js/search/local-search.js index 111dd07..c3cd40c 100644 --- a/source/js/search/local-search.js +++ b/source/js/search/local-search.js @@ -1,567 +1,567 @@ -/** - * Refer to hexo-generator-searchdb - * https://github.com/next-theme/hexo-generator-searchdb/blob/main/dist/search.js - * Modified by hexo-theme-butterfly - */ - -class LocalSearch { - constructor ({ - path = '', - unescape = false, - top_n_per_article = 1 - }) { - this.path = path - this.unescape = unescape - this.top_n_per_article = top_n_per_article - this.isfetched = false - this.datas = null - } - - getIndexByWord (words, text, caseSensitive = false) { - const index = [] - const included = new Set() - - if (!caseSensitive) { - text = text.toLowerCase() - } - words.forEach(word => { - if (this.unescape) { - const div = document.createElement('div') - div.innerText = word - word = div.innerHTML - } - const wordLen = word.length - if (wordLen === 0) return - let startPosition = 0 - let position = -1 - if (!caseSensitive) { - word = word.toLowerCase() - } - while ((position = text.indexOf(word, startPosition)) > -1) { - index.push({ position, word }) - included.add(word) - startPosition = position + wordLen - } - }) - // Sort index by position of keyword - index.sort((left, right) => { - if (left.position !== right.position) { - return left.position - right.position - } - return right.word.length - left.word.length - }) - return [index, included] - } - - // Merge hits into slices - mergeIntoSlice (start, end, index) { - let item = index[0] - let { position, word } = item - const hits = [] - const count = new Set() - while (position + word.length <= end && index.length !== 0) { - count.add(word) - hits.push({ - position, - length: word.length - }) - const wordEnd = position + word.length - - // Move to next position of hit - index.shift() - while (index.length !== 0) { - item = index[0] - position = item.position - word = item.word - if (wordEnd > position) { - index.shift() - } else { - break - } - } - } - return { - hits, - start, - end, - count: count.size - } - } - - // Highlight title and content - highlightKeyword (val, slice) { - let result = '' - let index = slice.start - for (const { position, length } of slice.hits) { - result += val.substring(index, position) - index = position + length - result += `${val.substr(position, length)}` - } - result += val.substring(index, slice.end) - return result - } - - getResultItems (keywords) { - const resultItems = [] - this.datas.forEach(({ title, content, url }) => { - // The number of different keywords included in the article. - const [indexOfTitle, keysOfTitle] = this.getIndexByWord(keywords, title) - const [indexOfContent, keysOfContent] = this.getIndexByWord(keywords, content) - const includedCount = new Set([...keysOfTitle, ...keysOfContent]).size - - // Show search results - const hitCount = indexOfTitle.length + indexOfContent.length - if (hitCount === 0) return - - const slicesOfTitle = [] - if (indexOfTitle.length !== 0) { - slicesOfTitle.push(this.mergeIntoSlice(0, title.length, indexOfTitle)) - } - - let slicesOfContent = [] - while (indexOfContent.length !== 0) { - const item = indexOfContent[0] - const { position } = item - // Cut out 120 characters. The maxlength of .search-input is 80. - const start = Math.max(0, position - 20) - const end = Math.min(content.length, position + 100) - slicesOfContent.push(this.mergeIntoSlice(start, end, indexOfContent)) - } - - // Sort slices in content by included keywords' count and hits' count - slicesOfContent.sort((left, right) => { - if (left.count !== right.count) { - return right.count - left.count - } else if (left.hits.length !== right.hits.length) { - return right.hits.length - left.hits.length - } - return left.start - right.start - }) - - // Select top N slices in content - const upperBound = parseInt(this.top_n_per_article, 10) - if (upperBound >= 0) { - slicesOfContent = slicesOfContent.slice(0, upperBound) - } - - let resultItem = '' - - url = new URL(url, location.origin) - url.searchParams.append('highlight', keywords.join(' ')) - - if (slicesOfTitle.length !== 0) { - resultItem += `
  • ${this.highlightKeyword(title, slicesOfTitle[0])}` - } else { - resultItem += `
  • ${title}` - } - - slicesOfContent.forEach(slice => { - resultItem += `

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

    ` - }) - - resultItem += '
  • ' - resultItems.push({ - item: resultItem, - id: resultItems.length, - hitCount, - includedCount - }) - }) - return resultItems - } - - fetchData () { - const isXml = !this.path.endsWith('json') - fetch(this.path) - .then(response => response.text()) - .then(res => { - // Get the contents from search data - this.isfetched = true - this.datas = isXml - ? [...new DOMParser().parseFromString(res, 'text/xml').querySelectorAll('entry')].map(element => ({ - title: element.querySelector('title').textContent, - content: element.querySelector('content').textContent, - url: element.querySelector('url').textContent - })) - : JSON.parse(res) - // Only match articles with non-empty titles - this.datas = this.datas.filter(data => data.title).map(data => { - data.title = data.title.trim() - data.content = data.content ? data.content.trim().replace(/<[^>]+>/g, '') : '' - data.url = decodeURIComponent(data.url).replace(/\/{2,}/g, '/') - return data - }) - // Remove loading animation - window.dispatchEvent(new Event('search:loaded')) - }) - } - - // Highlight by wrapping node in mark elements with the given class name - highlightText (node, slice, className) { - const val = node.nodeValue - let index = slice.start - const children = [] - for (const { position, length } of slice.hits) { - const text = document.createTextNode(val.substring(index, position)) - index = position + length - const mark = document.createElement('mark') - mark.className = className - mark.appendChild(document.createTextNode(val.substr(position, length))) - children.push(text, mark) - } - node.nodeValue = val.substring(index, slice.end) - children.forEach(element => { - node.parentNode.insertBefore(element, node) - }) - } - - // Highlight the search words provided in the url in the text - highlightSearchWords (body) { - const params = new URL(location.href).searchParams.get('highlight') - const keywords = params ? params.split(' ') : [] - if (!keywords.length || !body) return - const walk = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, null) - const allNodes = [] - while (walk.nextNode()) { - if (!walk.currentNode.parentNode.matches('button, select, textarea, .mermaid')) allNodes.push(walk.currentNode) - } - allNodes.forEach(node => { - const [indexOfNode] = this.getIndexByWord(keywords, node.nodeValue) - if (!indexOfNode.length) return - const slice = this.mergeIntoSlice(0, node.nodeValue.length, indexOfNode) - this.highlightText(node, slice, 'search-keyword') - }) - } -} - -window.addEventListener('load', () => { -// Search - const { path, top_n_per_article, unescape, languages, pagination } = GLOBAL_CONFIG.localSearch - const enablePagination = pagination && pagination.enable - const localSearch = new LocalSearch({ - path, - top_n_per_article, - unescape - }) - - const input = document.querySelector('.local-search-input input') - const statsItem = document.getElementById('local-search-stats') - const $loadingStatus = document.getElementById('loading-status') - const isXml = !path.endsWith('json') - - // Pagination variables (only initialize if pagination is enabled) - let currentPage = 0 - const hitsPerPage = pagination.hitsPerPage || 10 - - let currentResultItems = [] - - if (!enablePagination) { - // If pagination is disabled, we don't need these variables - currentPage = undefined - currentResultItems = undefined - } - - // Cache frequently used elements - const elements = { - get pagination () { return document.getElementById('local-search-pagination') }, - get paginationList () { return document.querySelector('#local-search-pagination .ais-Pagination-list') } - } - - // Show/hide search results area - const toggleResultsVisibility = hasResults => { - if (enablePagination) { - elements.pagination.style.display = hasResults ? '' : 'none' - } else { - elements.pagination.style.display = 'none' - } - } - - // Render search results for current page - const renderResults = (searchText, resultItems) => { - const container = document.getElementById('local-search-results') - - // Determine items to display based on pagination mode - const itemsToDisplay = enablePagination - ? currentResultItems.slice(currentPage * hitsPerPage, (currentPage + 1) * hitsPerPage) - : resultItems - - // Handle empty page in pagination mode - if (enablePagination && itemsToDisplay.length === 0 && currentResultItems.length > 0) { - currentPage = 0 - renderResults(searchText, resultItems) - return - } - - // Add numbering to items - const numberedItems = itemsToDisplay.map((result, index) => { - const itemNumber = enablePagination - ? currentPage * hitsPerPage + index + 1 - : index + 1 - return result.item.replace( - '
  • ', - `
  • ` - ) - }) - - container.innerHTML = `
      ${numberedItems.join('')}
    ` - - // Update stats - const displayCount = enablePagination ? currentResultItems.length : resultItems.length - const stats = languages.hits_stats.replace(/\$\{hits}/, displayCount) - statsItem.innerHTML = `
    ${stats}
    ` - - // Handle pagination - if (enablePagination) { - const nbPages = Math.ceil(currentResultItems.length / hitsPerPage) - renderPagination(currentPage, nbPages, searchText) - } - - const hasResults = resultItems.length > 0 - toggleResultsVisibility(hasResults) - - window.pjax && window.pjax.refresh(container) - } - - // Render pagination - const renderPagination = (page, nbPages, query) => { - if (nbPages <= 1) { - elements.pagination.style.display = 'none' - elements.paginationList.innerHTML = '' - return - } - - elements.pagination.style.display = 'block' - - const isFirstPage = page === 0 - const isLastPage = page === nbPages - 1 - - // Responsive page display - const isMobile = window.innerWidth < 768 - const maxVisiblePages = isMobile ? 3 : 5 - let startPage = Math.max(0, page - Math.floor(maxVisiblePages / 2)) - const endPage = Math.min(nbPages - 1, startPage + maxVisiblePages - 1) - - // Adjust starting page to maintain max visible pages - if (endPage - startPage + 1 < maxVisiblePages) { - startPage = Math.max(0, endPage - maxVisiblePages + 1) - } - - let pagesHTML = '' - - // Only add ellipsis and first page when there are many pages - if (nbPages > maxVisiblePages && startPage > 0) { - pagesHTML += ` -
  • - 1 -
  • ` - if (startPage > 1) { - pagesHTML += ` -
  • - ... -
  • ` - } - } - - // Add middle page numbers - for (let i = startPage; i <= endPage; i++) { - const isSelected = i === page - if (isSelected) { - pagesHTML += ` -
  • - ${i + 1} -
  • ` - } else { - pagesHTML += ` -
  • - ${i + 1} -
  • ` - } - } - - // Only add ellipsis and last page when there are many pages - if (nbPages > maxVisiblePages && endPage < nbPages - 1) { - if (endPage < nbPages - 2) { - pagesHTML += ` -
  • - ... -
  • ` - } - pagesHTML += ` -
  • - ${nbPages} -
  • ` - } - - if (nbPages > 1) { - elements.paginationList.innerHTML = ` -
  • - ${isFirstPage - ? '' - : `` - } -
  • - ${pagesHTML} -
  • - ${isLastPage - ? '' - : `` - } -
  • ` - } else { - elements.pagination.style.display = 'none' - } - } - - // Clear search results and stats - const clearSearchResults = () => { - const container = document.getElementById('local-search-results') - container.textContent = '' - statsItem.textContent = '' - toggleResultsVisibility(false) - if (enablePagination) { - currentResultItems = [] - currentPage = 0 - } - } - - // Show no results message - const showNoResults = searchText => { - const container = document.getElementById('local-search-results') - container.textContent = '' - const statsDiv = document.createElement('div') - statsDiv.className = 'search-result-stats' - statsDiv.textContent = languages.hits_empty.replace(/\$\{query}/, searchText) - statsItem.innerHTML = statsDiv.outerHTML - toggleResultsVisibility(false) - if (enablePagination) { - currentResultItems = [] - currentPage = 0 - } - } - - const inputEventFunction = () => { - if (!localSearch.isfetched) return - let searchText = input.value.trim().toLowerCase() - isXml && (searchText = searchText.replace(//g, '>')) - - if (searchText !== '') $loadingStatus.hidden = false - - const keywords = searchText.split(/[-\s]+/) - let resultItems = [] - - if (searchText.length > 0) { - resultItems = localSearch.getResultItems(keywords) - } - - if (keywords.length === 1 && keywords[0] === '') { - clearSearchResults() - } else if (resultItems.length === 0) { - showNoResults(searchText) - } else { - // Sort results by relevance - resultItems.sort((left, right) => { - if (left.includedCount !== right.includedCount) { - return right.includedCount - left.includedCount - } else if (left.hitCount !== right.hitCount) { - return right.hitCount - left.hitCount - } - return right.id - left.id - }) - - if (enablePagination) { - currentResultItems = resultItems - currentPage = 0 - } - renderResults(searchText, resultItems) - } - - $loadingStatus.hidden = true - } - - let loadFlag = false - const $searchMask = document.getElementById('search-mask') - const $searchDialog = document.querySelector('#local-search .search-dialog') - - // fix safari - const fixSafariHeight = () => { - if (window.innerWidth < 768) { - $searchDialog.style.setProperty('--search-height', window.innerHeight + 'px') - } - } - - const openSearch = () => { - btf.overflowPaddingR.add() - btf.animateIn($searchMask, 'to_show 0.5s') - btf.animateIn($searchDialog, 'titleScale 0.5s') - setTimeout(() => { input.focus() }, 300) - if (!loadFlag) { - !localSearch.isfetched && localSearch.fetchData() - input.addEventListener('input', inputEventFunction) - loadFlag = true - } - // shortcut: ESC - document.addEventListener('keydown', function f (event) { - if (event.code === 'Escape') { - closeSearch() - document.removeEventListener('keydown', f) - } - }) - - fixSafariHeight() - window.addEventListener('resize', fixSafariHeight) - } - - const closeSearch = () => { - btf.overflowPaddingR.remove() - btf.animateOut($searchDialog, 'search_close .5s') - btf.animateOut($searchMask, 'to_hide 0.5s') - window.removeEventListener('resize', fixSafariHeight) - } - - const searchClickFn = () => { - btf.addEventListenerPjax(document.querySelector('#search-button > .search'), 'click', openSearch) - } - - const searchFnOnce = () => { - document.querySelector('#local-search .search-close-button').addEventListener('click', closeSearch) - $searchMask.addEventListener('click', closeSearch) - if (GLOBAL_CONFIG.localSearch.preload) { - localSearch.fetchData() - } - localSearch.highlightSearchWords(document.getElementById('article-container')) - - // Pagination event delegation - only add if pagination is enabled - if (enablePagination) { - elements.pagination.addEventListener('click', e => { - e.preventDefault() - const link = e.target.closest('a[data-page]') - if (link) { - const page = parseInt(link.dataset.page, 10) - if (!isNaN(page) && currentResultItems.length > 0) { - currentPage = page - renderResults(input.value.trim().toLowerCase(), currentResultItems) - } - } - }) - } - - // Initial state - toggleResultsVisibility(false) - } - - window.addEventListener('search:loaded', () => { - const $loadDataItem = document.getElementById('loading-database') - $loadDataItem.nextElementSibling.style.visibility = 'visible' - $loadDataItem.remove() - }) - - searchClickFn() - searchFnOnce() - - // pjax - window.addEventListener('pjax:complete', () => { - !btf.isHidden($searchMask) && closeSearch() - localSearch.highlightSearchWords(document.getElementById('article-container')) - searchClickFn() - }) -}) +/** + * Refer to hexo-generator-searchdb + * https://github.com/next-theme/hexo-generator-searchdb/blob/main/dist/search.js + * Modified by hexo-theme-butterfly + */ + +class LocalSearch { + constructor ({ + path = '', + unescape = false, + top_n_per_article = 1 + }) { + this.path = path + this.unescape = unescape + this.top_n_per_article = top_n_per_article + this.isfetched = false + this.datas = null + this._unescapeDiv = unescape ? document.createElement('div') : null + this._processedKeywords = null + } + + _processKeywords (keywords) { + if (this._processedKeywords) return this._processedKeywords + this._processedKeywords = keywords.map(word => { + if (this.unescape) { + this._unescapeDiv.innerText = word + return this._unescapeDiv.innerHTML + } + return word + }) + return this._processedKeywords + } + + getIndexByWord (words, text, caseSensitive = false) { + const index = [] + const included = new Set() + const processedWords = this._processKeywords(words) + + if (!caseSensitive) { + text = text.toLowerCase() + } + processedWords.forEach((word, i) => { + const wordLen = word.length + if (wordLen === 0) return + let startPosition = 0 + let position = -1 + const searchWord = caseSensitive ? word : word.toLowerCase() + while ((position = text.indexOf(searchWord, startPosition)) > -1) { + index.push({ position, word }) + included.add(words[i]) + startPosition = position + wordLen + } + }) + // Sort index by position of keyword + index.sort((left, right) => { + if (left.position !== right.position) { + return left.position - right.position + } + return right.word.length - left.word.length + }) + return [index, included] + } + + // Merge hits into slices + mergeIntoSlice (start, end, index) { + let item = index[0] + let { position, word } = item + const hits = [] + const count = new Set() + while (position + word.length <= end && index.length !== 0) { + count.add(word) + hits.push({ + position, + length: word.length + }) + const wordEnd = position + word.length + + // Move to next position of hit + index.shift() + while (index.length !== 0) { + item = index[0] + position = item.position + word = item.word + if (wordEnd > position) { + index.shift() + } else { + break + } + } + } + return { + hits, + start, + end, + count: count.size + } + } + + // Highlight title and content + highlightKeyword (val, slice) { + const parts = [] + let index = slice.start + for (const { position, length } of slice.hits) { + parts.push(val.substring(index, position)) + index = position + length + parts.push(`${val.substring(position, position + length)}`) + } + parts.push(val.substring(index, slice.end)) + return parts.join('') + } + + getResultItems (keywords) { + const resultItems = [] + this._processedKeywords = null + // Compute highlight param once instead of per-article + const highlightParam = keywords.join(' ') + this.datas.forEach(({ title, content, url }) => { + // The number of different keywords included in the article. + const [indexOfTitle, keysOfTitle] = this.getIndexByWord(keywords, title) + const [indexOfContent, keysOfContent] = this.getIndexByWord(keywords, content) + const includedCount = new Set([...keysOfTitle, ...keysOfContent]).size + + // Show search results + const hitCount = indexOfTitle.length + indexOfContent.length + if (hitCount === 0) return + + const slicesOfTitle = [] + if (indexOfTitle.length !== 0) { + slicesOfTitle.push(this.mergeIntoSlice(0, title.length, indexOfTitle)) + } + + let slicesOfContent = [] + while (indexOfContent.length !== 0) { + const item = indexOfContent[0] + const { position } = item + // Cut out 120 characters. The maxlength of .search-input is 80. + const start = Math.max(0, position - 20) + const end = Math.min(content.length, position + 100) + slicesOfContent.push(this.mergeIntoSlice(start, end, indexOfContent)) + } + + // Sort slices in content by included keywords' count and hits' count + slicesOfContent.sort((left, right) => { + if (left.count !== right.count) { + return right.count - left.count + } else if (left.hits.length !== right.hits.length) { + return right.hits.length - left.hits.length + } + return left.start - right.start + }) + + // Select top N slices in content + const upperBound = parseInt(this.top_n_per_article, 10) + if (upperBound >= 0) { + slicesOfContent = slicesOfContent.slice(0, upperBound) + } + + let resultItem = '' + + url = new URL(url, location.origin) + url.searchParams.append('highlight', highlightParam) + + if (slicesOfTitle.length !== 0) { + resultItem += `
  • ${this.highlightKeyword(title, slicesOfTitle[0])}` + } else { + resultItem += `
  • ${title}` + } + + slicesOfContent.forEach(slice => { + resultItem += `

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

    ` + }) + + resultItem += '
  • ' + resultItems.push({ + item: resultItem, + id: resultItems.length, + hitCount, + includedCount + }) + }) + return resultItems + } + + fetchData () { + const isXml = !this.path.endsWith('json') + fetch(this.path) + .then(response => { + if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`) + return response.text() + }) + .then(res => { + // Get the contents from search data + this.isfetched = true + this.datas = isXml + ? [...new DOMParser().parseFromString(res, 'text/xml').querySelectorAll('entry')].map(element => ({ + title: element.querySelector('title').textContent, + content: element.querySelector('content').textContent, + url: element.querySelector('url').textContent + })) + : JSON.parse(res) + // Only match articles with non-empty titles + this.datas = this.datas.filter(data => data.title).map(data => { + data.title = data.title.trim() + data.content = data.content ? data.content.trim().replace(/<[^>]+>/g, '') : '' + data.url = decodeURIComponent(data.url).replace(/\/{2,}/g, '/') + return data + }) + // Remove loading animation + window.dispatchEvent(new Event('search:loaded')) + }) + .catch(error => { + console.error('Local search data fetch failed:', error) + this.isfetched = true + this.datas = [] + window.dispatchEvent(new Event('search:loaded')) + }) + } + + // Highlight by wrapping node in mark elements with the given class name + highlightText (node, slice, className) { + const val = node.nodeValue + let index = slice.start + const children = [] + for (const { position, length } of slice.hits) { + const text = document.createTextNode(val.substring(index, position)) + index = position + length + const mark = document.createElement('mark') + mark.className = className + mark.appendChild(document.createTextNode(val.substring(position, position + length))) + children.push(text, mark) + } + node.nodeValue = val.substring(index, slice.end) + children.forEach(element => { + node.parentNode.insertBefore(element, node) + }) + } + + // Highlight the search words provided in the url in the text + highlightSearchWords (body) { + const params = new URL(location.href).searchParams.get('highlight') + const keywords = params ? params.split(' ') : [] + if (!keywords.length || !body) return + const walk = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, null) + const allNodes = [] + while (walk.nextNode()) { + if (!walk.currentNode.parentNode.matches('button, select, textarea, .mermaid')) allNodes.push(walk.currentNode) + } + allNodes.forEach(node => { + const [indexOfNode] = this.getIndexByWord(keywords, node.nodeValue) + if (!indexOfNode.length) return + const slice = this.mergeIntoSlice(0, node.nodeValue.length, indexOfNode) + this.highlightText(node, slice, 'search-keyword') + }) + } +} + +window.addEventListener('load', () => { + // Search + const { path, top_n_per_article, unescape, languages, pagination } = GLOBAL_CONFIG.localSearch + const enablePagination = pagination && pagination.enable + const localSearch = new LocalSearch({ + path, + top_n_per_article, + unescape + }) + + const $input = document.querySelector('.local-search-input input') + const $statsItem = document.getElementById('local-search-stats') + const $loadingStatus = document.getElementById('loading-status') + const $searchMask = document.getElementById('search-mask') + const $searchDialog = document.querySelector('#local-search .search-dialog') + const $results = document.getElementById('local-search-results') + const $pagination = document.getElementById('local-search-pagination') + const $paginationList = document.querySelector('#local-search-pagination .ais-Pagination-list') + const isXml = !path.endsWith('json') + + // Pagination variables (only initialize if pagination is enabled) + let currentPage = 0 + const hitsPerPage = pagination.hitsPerPage || 10 + + let currentResultItems = [] + + if (!enablePagination) { + currentPage = undefined + currentResultItems = undefined + } + + // Show/hide search results area + const toggleResultsVisibility = hasResults => { + $pagination.style.display = (hasResults && enablePagination) ? '' : 'none' + } + + // Render search results for current page + const renderResults = (searchText, resultItems) => { + // Determine items to display based on pagination mode + const itemsToDisplay = enablePagination + ? currentResultItems.slice(currentPage * hitsPerPage, (currentPage + 1) * hitsPerPage) + : resultItems + + // Handle empty page in pagination mode + if (enablePagination && itemsToDisplay.length === 0 && currentResultItems.length > 0) { + currentPage = 0 + renderResults(searchText, resultItems) + return + } + + // Add numbering to items + const numberedItems = itemsToDisplay.map((result, index) => { + const itemNumber = enablePagination + ? currentPage * hitsPerPage + index + 1 + : index + 1 + return result.item.replace( + '
  • ', + `
  • ` + ) + }) + + $results.innerHTML = `
      ${numberedItems.join('')}
    ` + + // Update stats + const displayCount = enablePagination ? currentResultItems.length : resultItems.length + const stats = languages.hits_stats.replace(/\$\{hits}/, displayCount) + $statsItem.innerHTML = `
    ${stats}
    ` + + // Handle pagination + if (enablePagination) { + const nbPages = Math.ceil(currentResultItems.length / hitsPerPage) + renderPagination(currentPage, nbPages, searchText) + } + + const hasResults = resultItems.length > 0 + toggleResultsVisibility(hasResults) + + window.pjax && window.pjax.refresh($results) + } + + // Render pagination + const renderPagination = (page, nbPages) => { + if (nbPages <= 1) { + $pagination.style.display = 'none' + $paginationList.innerHTML = '' + return + } + + const isFirstPage = page === 0 + const isLastPage = page === nbPages - 1 + + // Responsive page display + const isMobile = window.innerWidth < 768 + const maxVisiblePages = isMobile ? 3 : 5 + let startPage = Math.max(0, page - Math.floor(maxVisiblePages / 2)) + const endPage = Math.min(nbPages - 1, startPage + maxVisiblePages - 1) + + // Adjust starting page to maintain max visible pages + if (endPage - startPage + 1 < maxVisiblePages) { + startPage = Math.max(0, endPage - maxVisiblePages + 1) + } + + const parts = [] + + // Only add ellipsis and first page when there are many pages + if (nbPages > maxVisiblePages && startPage > 0) { + parts.push('
  • 1
  • ') + if (startPage > 1) { + parts.push('
  • ...
  • ') + } + } + + // Add middle page numbers + for (let i = startPage; i <= endPage; i++) { + if (i === page) { + parts.push(`
  • ${i + 1}
  • `) + } else { + parts.push(`
  • ${i + 1}
  • `) + } + } + + // Only add ellipsis and last page when there are many pages + if (nbPages > maxVisiblePages && endPage < nbPages - 1) { + if (endPage < nbPages - 2) { + parts.push('
  • ...
  • ') + } + parts.push(`
  • ${nbPages}
  • `) + } + + // Build prev/next links + const prevLink = isFirstPage + ? '' + : `` + const nextLink = isLastPage + ? '' + : `` + + $paginationList.innerHTML = `
  • ${prevLink}
  • ${parts.join('')}
  • ${nextLink}
  • ` + $pagination.style.display = '' + } + + // Clear search results and stats + const clearSearchResults = () => { + $results.textContent = '' + $statsItem.textContent = '' + toggleResultsVisibility(false) + if (enablePagination) { + currentResultItems = [] + currentPage = 0 + } + } + + // Show no results message + const showNoResults = searchText => { + $results.textContent = '' + const statsDiv = document.createElement('div') + statsDiv.className = 'search-result-stats' + statsDiv.textContent = languages.hits_empty.replace(/\$\{query}/, searchText) + $statsItem.innerHTML = statsDiv.outerHTML + toggleResultsVisibility(false) + if (enablePagination) { + currentResultItems = [] + currentPage = 0 + } + } + + const inputEventFunction = () => { + if (!localSearch.isfetched) return + let searchText = $input.value.trim().toLowerCase() + isXml && (searchText = searchText.replace(//g, '>')) + + if (searchText !== '') $loadingStatus.hidden = false + + const keywords = searchText.split(/[-\s]+/) + let resultItems = [] + + if (searchText.length > 0) { + resultItems = localSearch.getResultItems(keywords) + } + + if (keywords.length === 1 && keywords[0] === '') { + clearSearchResults() + } else if (resultItems.length === 0) { + showNoResults(searchText) + } else { + // Sort results by relevance + resultItems.sort((left, right) => { + if (left.includedCount !== right.includedCount) { + return right.includedCount - left.includedCount + } else if (left.hitCount !== right.hitCount) { + return right.hitCount - left.hitCount + } + return right.id - left.id + }) + + if (enablePagination) { + currentResultItems = resultItems + currentPage = 0 + } + renderResults(searchText, resultItems) + } + + $loadingStatus.hidden = true + } + + // Debounced input handler + let searchTimeout + const debouncedInputEvent = () => { + clearTimeout(searchTimeout) + // Empty input: clear results immediately without debounce delay + if (!$input.value.trim()) { + inputEventFunction() + return + } + searchTimeout = setTimeout(inputEventFunction, 200) + } + + let loadFlag = false + + const fixSafariHeight = () => { + if (window.innerWidth < 768) { + $searchDialog.style.setProperty('--search-height', window.innerHeight + 'px') + } + } + + // Debounced resize to avoid layout thrashing + let resizeTimer + const onResize = () => { + clearTimeout(resizeTimer) + resizeTimer = setTimeout(fixSafariHeight, 150) + } + + const handleEscape = event => { + if (event.code === 'Escape') { + closeSearch() + document.removeEventListener('keydown', handleEscape) + } + } + + const openSearch = () => { + btf.overflowPaddingR.add() + btf.animateIn($searchMask, 'to_show 0.5s') + btf.animateIn($searchDialog, 'titleScale 0.5s') + setTimeout(() => { $input.focus() }, 300) + if (!loadFlag) { + !localSearch.isfetched && localSearch.fetchData() + $input.addEventListener('input', debouncedInputEvent) + loadFlag = true + } + // shortcut: ESC + document.addEventListener('keydown', handleEscape) + + fixSafariHeight() + window.addEventListener('resize', onResize) + } + + const closeSearch = () => { + btf.overflowPaddingR.remove() + btf.animateOut($searchDialog, 'search_close .5s') + btf.animateOut($searchMask, 'to_hide 0.5s') + document.removeEventListener('keydown', handleEscape) + window.removeEventListener('resize', onResize) + } + + const searchClickFn = () => { + btf.addEventListenerPjax(document.querySelector('#search-button > .search'), 'click', openSearch) + } + + const searchFnOnce = () => { + document.querySelector('#local-search .search-close-button').addEventListener('click', closeSearch) + $searchMask.addEventListener('click', closeSearch) + if (GLOBAL_CONFIG.localSearch.preload) { + localSearch.fetchData() + } + localSearch.highlightSearchWords(document.getElementById('article-container')) + + // Pagination event delegation - only add if pagination is enabled + if (enablePagination) { + $pagination.addEventListener('click', e => { + e.preventDefault() + const link = e.target.closest('a[data-page]') + if (link) { + const page = parseInt(link.dataset.page, 10) + if (!isNaN(page) && currentResultItems.length > 0) { + currentPage = page + renderResults($input.value.trim().toLowerCase(), currentResultItems) + } + } + }) + } + + // Initial state + toggleResultsVisibility(false) + } + + window.addEventListener('search:loaded', () => { + const $loadDataItem = document.getElementById('loading-database') + $loadDataItem.nextElementSibling.style.visibility = 'visible' + $loadDataItem.remove() + }) + + searchClickFn() + searchFnOnce() + + // pjax + window.addEventListener('pjax:complete', () => { + !btf.isHidden($searchMask) && closeSearch() + localSearch.highlightSearchWords(document.getElementById('article-container')) + searchClickFn() + }) +}) diff --git a/source/js/tw_cn.js b/source/js/tw_cn.js index 3a7cb32..d43ae2a 100644 --- a/source/js/tw_cn.js +++ b/source/js/tw_cn.js @@ -5,7 +5,7 @@ document.addEventListener('DOMContentLoaded', () => { let currentEncoding = defaultEncoding let targetEncoding = Number(btf.saveToLocal.get(targetEncodingCookie)) || defaultEncoding - const translateButtonObject = document.getElementById('translateLink') + let translateButtonObject = document.getElementById('translateLink') const isSnackbar = snackbarData !== undefined const setLang = () => { @@ -68,31 +68,44 @@ document.addEventListener('DOMContentLoaded', () => { const JTPYStr = () => '万与丑专业丛东丝丢两严丧个丬丰临为丽举么义乌乐乔习乡书买乱争于亏云亘亚产亩亲亵亸亿仅从仑仓仪们价众优伙会伛伞伟传伤伥伦伧伪伫体余佣佥侠侣侥侦侧侨侩侪侬俣俦俨俩俪俭债倾偬偻偾偿傥傧储傩儿兑兖党兰关兴兹养兽冁内冈册写军农冢冯冲决况冻净凄凉凌减凑凛几凤凫凭凯击凼凿刍划刘则刚创删别刬刭刽刿剀剂剐剑剥剧劝办务劢动励劲劳势勋勐勚匀匦匮区医华协单卖卢卤卧卫却卺厂厅历厉压厌厍厕厢厣厦厨厩厮县参叆叇双发变叙叠叶号叹叽吁后吓吕吗吣吨听启吴呒呓呕呖呗员呙呛呜咏咔咙咛咝咤咴咸哌响哑哒哓哔哕哗哙哜哝哟唛唝唠唡唢唣唤唿啧啬啭啮啰啴啸喷喽喾嗫呵嗳嘘嘤嘱噜噼嚣嚯团园囱围囵国图圆圣圹场坂坏块坚坛坜坝坞坟坠垄垅垆垒垦垧垩垫垭垯垱垲垴埘埙埚埝埯堑堕塆墙壮声壳壶壸处备复够头夸夹夺奁奂奋奖奥妆妇妈妩妪妫姗姜娄娅娆娇娈娱娲娴婳婴婵婶媪嫒嫔嫱嬷孙学孪宁宝实宠审宪宫宽宾寝对寻导寿将尔尘尧尴尸尽层屃屉届属屡屦屿岁岂岖岗岘岙岚岛岭岳岽岿峃峄峡峣峤峥峦崂崃崄崭嵘嵚嵛嵝嵴巅巩巯币帅师帏帐帘帜带帧帮帱帻帼幂幞干并广庄庆庐庑库应庙庞废庼廪开异弃张弥弪弯弹强归当录彟彦彻径徕御忆忏忧忾怀态怂怃怄怅怆怜总怼怿恋恳恶恸恹恺恻恼恽悦悫悬悭悯惊惧惨惩惫惬惭惮惯愍愠愤愦愿慑慭憷懑懒懔戆戋戏戗战戬户扎扑扦执扩扪扫扬扰抚抛抟抠抡抢护报担拟拢拣拥拦拧拨择挂挚挛挜挝挞挟挠挡挢挣挤挥挦捞损捡换捣据捻掳掴掷掸掺掼揸揽揿搀搁搂搅携摄摅摆摇摈摊撄撑撵撷撸撺擞攒敌敛数斋斓斗斩断无旧时旷旸昙昼昽显晋晒晓晔晕晖暂暧札术朴机杀杂权条来杨杩杰极构枞枢枣枥枧枨枪枫枭柜柠柽栀栅标栈栉栊栋栌栎栏树栖样栾桊桠桡桢档桤桥桦桧桨桩梦梼梾检棂椁椟椠椤椭楼榄榇榈榉槚槛槟槠横樯樱橥橱橹橼檐檩欢欤欧歼殁殇残殒殓殚殡殴毁毂毕毙毡毵氇气氢氩氲汇汉污汤汹沓沟没沣沤沥沦沧沨沩沪沵泞泪泶泷泸泺泻泼泽泾洁洒洼浃浅浆浇浈浉浊测浍济浏浐浑浒浓浔浕涂涌涛涝涞涟涠涡涢涣涤润涧涨涩淀渊渌渍渎渐渑渔渖渗温游湾湿溃溅溆溇滗滚滞滟滠满滢滤滥滦滨滩滪漤潆潇潋潍潜潴澜濑濒灏灭灯灵灾灿炀炉炖炜炝点炼炽烁烂烃烛烟烦烧烨烩烫烬热焕焖焘煅煳熘爱爷牍牦牵牺犊犟状犷犸犹狈狍狝狞独狭狮狯狰狱狲猃猎猕猡猪猫猬献獭玑玙玚玛玮环现玱玺珉珏珐珑珰珲琎琏琐琼瑶瑷璇璎瓒瓮瓯电画畅畲畴疖疗疟疠疡疬疮疯疱疴痈痉痒痖痨痪痫痴瘅瘆瘗瘘瘪瘫瘾瘿癞癣癫癯皑皱皲盏盐监盖盗盘眍眦眬着睁睐睑瞒瞩矫矶矾矿砀码砖砗砚砜砺砻砾础硁硅硕硖硗硙硚确硷碍碛碜碱碹磙礼祎祢祯祷祸禀禄禅离秃秆种积称秽秾稆税稣稳穑穷窃窍窑窜窝窥窦窭竖竞笃笋笔笕笺笼笾筑筚筛筜筝筹签简箓箦箧箨箩箪箫篑篓篮篱簖籁籴类籼粜粝粤粪粮糁糇紧絷纟纠纡红纣纤纥约级纨纩纪纫纬纭纮纯纰纱纲纳纴纵纶纷纸纹纺纻纼纽纾线绀绁绂练组绅细织终绉绊绋绌绍绎经绐绑绒结绔绕绖绗绘给绚绛络绝绞统绠绡绢绣绤绥绦继绨绩绪绫绬续绮绯绰绱绲绳维绵绶绷绸绹绺绻综绽绾绿缀缁缂缃缄缅缆缇缈缉缊缋缌缍缎缏缐缑缒缓缔缕编缗缘缙缚缛缜缝缞缟缠缡缢缣缤缥缦缧缨缩缪缫缬缭缮缯缰缱缲缳缴缵罂网罗罚罢罴羁羟羡翘翙翚耢耧耸耻聂聋职聍联聩聪肃肠肤肷肾肿胀胁胆胜胧胨胪胫胶脉脍脏脐脑脓脔脚脱脶脸腊腌腘腭腻腼腽腾膑臜舆舣舰舱舻艰艳艹艺节芈芗芜芦苁苇苈苋苌苍苎苏苘苹茎茏茑茔茕茧荆荐荙荚荛荜荞荟荠荡荣荤荥荦荧荨荩荪荫荬荭荮药莅莜莱莲莳莴莶获莸莹莺莼萚萝萤营萦萧萨葱蒇蒉蒋蒌蓝蓟蓠蓣蓥蓦蔷蔹蔺蔼蕲蕴薮藁藓虏虑虚虫虬虮虽虾虿蚀蚁蚂蚕蚝蚬蛊蛎蛏蛮蛰蛱蛲蛳蛴蜕蜗蜡蝇蝈蝉蝎蝼蝾螀螨蟏衅衔补衬衮袄袅袆袜袭袯装裆裈裢裣裤裥褛褴襁襕见观觃规觅视觇览觉觊觋觌觍觎觏觐觑觞触觯詟誉誊讠计订讣认讥讦讧讨让讪讫训议讯记讱讲讳讴讵讶讷许讹论讻讼讽设访诀证诂诃评诅识诇诈诉诊诋诌词诎诏诐译诒诓诔试诖诗诘诙诚诛诜话诞诟诠诡询诣诤该详诧诨诩诪诫诬语诮误诰诱诲诳说诵诶请诸诹诺读诼诽课诿谀谁谂调谄谅谆谇谈谊谋谌谍谎谏谐谑谒谓谔谕谖谗谘谙谚谛谜谝谞谟谠谡谢谣谤谥谦谧谨谩谪谫谬谭谮谯谰谱谲谳谴谵谶谷豮贝贞负贠贡财责贤败账货质贩贪贫贬购贮贯贰贱贲贳贴贵贶贷贸费贺贻贼贽贾贿赀赁赂赃资赅赆赇赈赉赊赋赌赍赎赏赐赑赒赓赔赕赖赗赘赙赚赛赜赝赞赟赠赡赢赣赪赵赶趋趱趸跃跄跖跞践跶跷跸跹跻踊踌踪踬踯蹑蹒蹰蹿躏躜躯车轧轨轩轪轫转轭轮软轰轱轲轳轴轵轶轷轸轹轺轻轼载轾轿辀辁辂较辄辅辆辇辈辉辊辋辌辍辎辏辐辑辒输辔辕辖辗辘辙辚辞辩辫边辽达迁过迈运还这进远违连迟迩迳迹适选逊递逦逻遗遥邓邝邬邮邹邺邻郁郄郏郐郑郓郦郧郸酝酦酱酽酾酿释里鉅鉴銮錾钆钇针钉钊钋钌钍钎钏钐钑钒钓钔钕钖钗钘钙钚钛钝钞钟钠钡钢钣钤钥钦钧钨钩钪钫钬钭钮钯钰钱钲钳钴钵钶钷钸钹钺钻钼钽钾钿铀铁铂铃铄铅铆铈铉铊铋铍铎铏铐铑铒铕铗铘铙铚铛铜铝铞铟铠铡铢铣铤铥铦铧铨铪铫铬铭铮铯铰铱铲铳铴铵银铷铸铹铺铻铼铽链铿销锁锂锃锄锅锆锇锈锉锊锋锌锍锎锏锐锑锒锓锔锕锖锗错锚锜锞锟锠锡锢锣锤锥锦锨锩锫锬锭键锯锰锱锲锳锴锵锶锷锸锹锺锻锼锽锾锿镀镁镂镃镆镇镈镉镊镌镍镎镏镐镑镒镕镖镗镙镚镛镜镝镞镟镠镡镢镣镤镥镦镧镨镩镪镫镬镭镮镯镰镱镲镳镴镶长门闩闪闫闬闭问闯闰闱闲闳间闵闶闷闸闹闺闻闼闽闾闿阀阁阂阃阄阅阆阇阈阉阊阋阌阍阎阏阐阑阒阓阔阕阖阗阘阙阚阛队阳阴阵阶际陆陇陈陉陕陧陨险随隐隶隽难雏雠雳雾霁霉霭靓静靥鞑鞒鞯鞴韦韧韨韩韪韫韬韵页顶顷顸项顺须顼顽顾顿颀颁颂颃预颅领颇颈颉颊颋颌颍颎颏颐频颒颓颔颕颖颗题颙颚颛颜额颞颟颠颡颢颣颤颥颦颧风飏飐飑飒飓飔飕飖飗飘飙飚飞飨餍饤饥饦饧饨饩饪饫饬饭饮饯饰饱饲饳饴饵饶饷饸饹饺饻饼饽饾饿馀馁馂馃馄馅馆馇馈馉馊馋馌馍馎馏馐馑馒馓馔馕马驭驮驯驰驱驲驳驴驵驶驷驸驹驺驻驼驽驾驿骀骁骂骃骄骅骆骇骈骉骊骋验骍骎骏骐骑骒骓骔骕骖骗骘骙骚骛骜骝骞骟骠骡骢骣骤骥骦骧髅髋髌鬓魇魉鱼鱽鱾鱿鲀鲁鲂鲄鲅鲆鲇鲈鲉鲊鲋鲌鲍鲎鲏鲐鲑鲒鲓鲔鲕鲖鲗鲘鲙鲚鲛鲜鲝鲞鲟鲠鲡鲢鲣鲤鲥鲦鲧鲨鲩鲪鲫鲬鲭鲮鲯鲰鲱鲲鲳鲴鲵鲶鲷鲸鲹鲺鲻鲼鲽鲾鲿鳀鳁鳂鳃鳄鳅鳆鳇鳈鳉鳊鳋鳌鳍鳎鳏鳐鳑鳒鳓鳔鳕鳖鳗鳘鳙鳛鳜鳝鳞鳟鳠鳡鳢鳣鸟鸠鸡鸢鸣鸤鸥鸦鸧鸨鸩鸪鸫鸬鸭鸮鸯鸰鸱鸲鸳鸴鸵鸶鸷鸸鸹鸺鸻鸼鸽鸾鸿鹀鹁鹂鹃鹄鹅鹆鹇鹈鹉鹊鹋鹌鹍鹎鹏鹐鹑鹒鹓鹔鹕鹖鹗鹘鹚鹛鹜鹝鹞鹟鹠鹡鹢鹣鹤鹥鹦鹧鹨鹩鹪鹫鹬鹭鹯鹰鹱鹲鹳鹴鹾麦麸黄黉黡黩黪黾龙历志制一台皋准复猛钟注范签' const FTPYStr = () => '萬與醜專業叢東絲丟兩嚴喪個爿豐臨為麗舉麼義烏樂喬習鄉書買亂爭於虧雲亙亞產畝親褻嚲億僅從侖倉儀們價眾優夥會傴傘偉傳傷倀倫傖偽佇體餘傭僉俠侶僥偵側僑儈儕儂俁儔儼倆儷儉債傾傯僂僨償儻儐儲儺兒兌兗黨蘭關興茲養獸囅內岡冊寫軍農塚馮衝決況凍淨淒涼淩減湊凜幾鳳鳧憑凱擊氹鑿芻劃劉則剛創刪別剗剄劊劌剴劑剮劍剝劇勸辦務勱動勵勁勞勢勳猛勩勻匭匱區醫華協單賣盧鹵臥衛卻巹廠廳曆厲壓厭厙廁廂厴廈廚廄廝縣參靉靆雙發變敘疊葉號歎嘰籲後嚇呂嗎唚噸聽啟吳嘸囈嘔嚦唄員咼嗆嗚詠哢嚨嚀噝吒噅鹹呱響啞噠嘵嗶噦嘩噲嚌噥喲嘜嗊嘮啢嗩唕喚呼嘖嗇囀齧囉嘽嘯噴嘍嚳囁嗬噯噓嚶囑嚕劈囂謔團園囪圍圇國圖圓聖壙場阪壞塊堅壇壢壩塢墳墜壟壟壚壘墾坰堊墊埡墶壋塏堖塒塤堝墊垵塹墮壪牆壯聲殼壺壼處備複夠頭誇夾奪奩奐奮獎奧妝婦媽嫵嫗媯姍薑婁婭嬈嬌孌娛媧嫻嫿嬰嬋嬸媼嬡嬪嬙嬤孫學孿寧寶實寵審憲宮寬賓寢對尋導壽將爾塵堯尷屍盡層屭屜屆屬屢屨嶼歲豈嶇崗峴嶴嵐島嶺嶽崠巋嶨嶧峽嶢嶠崢巒嶗崍嶮嶄嶸嶔崳嶁脊巔鞏巰幣帥師幃帳簾幟帶幀幫幬幘幗冪襆幹並廣莊慶廬廡庫應廟龐廢廎廩開異棄張彌弳彎彈強歸當錄彠彥徹徑徠禦憶懺憂愾懷態慫憮慪悵愴憐總懟懌戀懇惡慟懨愷惻惱惲悅愨懸慳憫驚懼慘懲憊愜慚憚慣湣慍憤憒願懾憖怵懣懶懍戇戔戲戧戰戩戶紮撲扡執擴捫掃揚擾撫拋摶摳掄搶護報擔擬攏揀擁攔擰撥擇掛摯攣掗撾撻挾撓擋撟掙擠揮撏撈損撿換搗據撚擄摑擲撣摻摜摣攬撳攙擱摟攪攜攝攄擺搖擯攤攖撐攆擷擼攛擻攢敵斂數齋斕鬥斬斷無舊時曠暘曇晝曨顯晉曬曉曄暈暉暫曖劄術樸機殺雜權條來楊榪傑極構樅樞棗櫪梘棖槍楓梟櫃檸檉梔柵標棧櫛櫳棟櫨櫟欄樹棲樣欒棬椏橈楨檔榿橋樺檜槳樁夢檮棶檢欞槨櫝槧欏橢樓欖櫬櫚櫸檟檻檳櫧橫檣櫻櫫櫥櫓櫞簷檁歡歟歐殲歿殤殘殞殮殫殯毆毀轂畢斃氈毿氌氣氫氬氳彙漢汙湯洶遝溝沒灃漚瀝淪滄渢溈滬濔濘淚澩瀧瀘濼瀉潑澤涇潔灑窪浹淺漿澆湞溮濁測澮濟瀏滻渾滸濃潯濜塗湧濤澇淶漣潿渦溳渙滌潤澗漲澀澱淵淥漬瀆漸澠漁瀋滲溫遊灣濕潰濺漵漊潷滾滯灩灄滿瀅濾濫灤濱灘澦濫瀠瀟瀲濰潛瀦瀾瀨瀕灝滅燈靈災燦煬爐燉煒熗點煉熾爍爛烴燭煙煩燒燁燴燙燼熱煥燜燾煆糊溜愛爺牘犛牽犧犢強狀獷獁猶狽麅獮獰獨狹獅獪猙獄猻獫獵獼玀豬貓蝟獻獺璣璵瑒瑪瑋環現瑲璽瑉玨琺瓏璫琿璡璉瑣瓊瑤璦璿瓔瓚甕甌電畫暢佘疇癤療瘧癘瘍鬁瘡瘋皰屙癰痙癢瘂癆瘓癇癡癉瘮瘞瘺癟癱癮癭癩癬癲臒皚皺皸盞鹽監蓋盜盤瞘眥矓著睜睞瞼瞞矚矯磯礬礦碭碼磚硨硯碸礪礱礫礎硜矽碩硤磽磑礄確鹼礙磧磣堿镟滾禮禕禰禎禱禍稟祿禪離禿稈種積稱穢穠穭稅穌穩穡窮竊竅窯竄窩窺竇窶豎競篤筍筆筧箋籠籩築篳篩簹箏籌簽簡籙簀篋籜籮簞簫簣簍籃籬籪籟糴類秈糶糲粵糞糧糝餱緊縶糸糾紆紅紂纖紇約級紈纊紀紉緯紜紘純紕紗綱納紝縱綸紛紙紋紡紵紖紐紓線紺絏紱練組紳細織終縐絆紼絀紹繹經紿綁絨結絝繞絰絎繪給絢絳絡絕絞統綆綃絹繡綌綏絛繼綈績緒綾緓續綺緋綽緔緄繩維綿綬繃綢綯綹綣綜綻綰綠綴緇緙緗緘緬纜緹緲緝縕繢緦綞緞緶線緱縋緩締縷編緡緣縉縛縟縝縫縗縞纏縭縊縑繽縹縵縲纓縮繆繅纈繚繕繒韁繾繰繯繳纘罌網羅罰罷羆羈羥羨翹翽翬耮耬聳恥聶聾職聹聯聵聰肅腸膚膁腎腫脹脅膽勝朧腖臚脛膠脈膾髒臍腦膿臠腳脫腡臉臘醃膕齶膩靦膃騰臏臢輿艤艦艙艫艱豔艸藝節羋薌蕪蘆蓯葦藶莧萇蒼苧蘇檾蘋莖蘢蔦塋煢繭荊薦薘莢蕘蓽蕎薈薺蕩榮葷滎犖熒蕁藎蓀蔭蕒葒葤藥蒞蓧萊蓮蒔萵薟獲蕕瑩鶯蓴蘀蘿螢營縈蕭薩蔥蕆蕢蔣蔞藍薊蘺蕷鎣驀薔蘞藺藹蘄蘊藪槁蘚虜慮虛蟲虯蟣雖蝦蠆蝕蟻螞蠶蠔蜆蠱蠣蟶蠻蟄蛺蟯螄蠐蛻蝸蠟蠅蟈蟬蠍螻蠑螿蟎蠨釁銜補襯袞襖嫋褘襪襲襏裝襠褌褳襝褲襇褸襤繈襴見觀覎規覓視覘覽覺覬覡覿覥覦覯覲覷觴觸觶讋譽謄訁計訂訃認譏訐訌討讓訕訖訓議訊記訒講諱謳詎訝訥許訛論訩訟諷設訪訣證詁訶評詛識詗詐訴診詆謅詞詘詔詖譯詒誆誄試詿詩詰詼誠誅詵話誕詬詮詭詢詣諍該詳詫諢詡譸誡誣語誚誤誥誘誨誑說誦誒請諸諏諾讀諑誹課諉諛誰諗調諂諒諄誶談誼謀諶諜謊諫諧謔謁謂諤諭諼讒諮諳諺諦謎諞諝謨讜謖謝謠謗諡謙謐謹謾謫譾謬譚譖譙讕譜譎讞譴譫讖穀豶貝貞負貟貢財責賢敗賬貨質販貪貧貶購貯貫貳賤賁貰貼貴貺貸貿費賀貽賊贄賈賄貲賃賂贓資賅贐賕賑賚賒賦賭齎贖賞賜贔賙賡賠賧賴賵贅賻賺賽賾贗讚贇贈贍贏贛赬趙趕趨趲躉躍蹌蹠躒踐躂蹺蹕躚躋踴躊蹤躓躑躡蹣躕躥躪躦軀車軋軌軒軑軔轉軛輪軟轟軲軻轤軸軹軼軤軫轢軺輕軾載輊轎輈輇輅較輒輔輛輦輩輝輥輞輬輟輜輳輻輯轀輸轡轅轄輾轆轍轔辭辯辮邊遼達遷過邁運還這進遠違連遲邇逕跡適選遜遞邐邏遺遙鄧鄺鄔郵鄒鄴鄰鬱郤郟鄶鄭鄆酈鄖鄲醞醱醬釅釃釀釋裏钜鑒鑾鏨釓釔針釘釗釙釕釷釺釧釤鈒釩釣鍆釹鍚釵鈃鈣鈈鈦鈍鈔鍾鈉鋇鋼鈑鈐鑰欽鈞鎢鉤鈧鈁鈥鈄鈕鈀鈺錢鉦鉗鈷缽鈳鉕鈽鈸鉞鑽鉬鉭鉀鈿鈾鐵鉑鈴鑠鉛鉚鈰鉉鉈鉍鈹鐸鉶銬銠鉺銪鋏鋣鐃銍鐺銅鋁銱銦鎧鍘銖銑鋌銩銛鏵銓鉿銚鉻銘錚銫鉸銥鏟銃鐋銨銀銣鑄鐒鋪鋙錸鋱鏈鏗銷鎖鋰鋥鋤鍋鋯鋨鏽銼鋝鋒鋅鋶鐦鐧銳銻鋃鋟鋦錒錆鍺錯錨錡錁錕錩錫錮鑼錘錐錦鍁錈錇錟錠鍵鋸錳錙鍥鍈鍇鏘鍶鍔鍤鍬鍾鍛鎪鍠鍰鎄鍍鎂鏤鎡鏌鎮鎛鎘鑷鐫鎳鎿鎦鎬鎊鎰鎔鏢鏜鏍鏰鏞鏡鏑鏃鏇鏐鐔钁鐐鏷鑥鐓鑭鐠鑹鏹鐙鑊鐳鐶鐲鐮鐿鑔鑣鑞鑲長門閂閃閆閈閉問闖閏闈閑閎間閔閌悶閘鬧閨聞闥閩閭闓閥閣閡閫鬮閱閬闍閾閹閶鬩閿閽閻閼闡闌闃闠闊闋闔闐闒闕闞闤隊陽陰陣階際陸隴陳陘陝隉隕險隨隱隸雋難雛讎靂霧霽黴靄靚靜靨韃鞽韉韝韋韌韍韓韙韞韜韻頁頂頃頇項順須頊頑顧頓頎頒頌頏預顱領頗頸頡頰頲頜潁熲頦頤頻頮頹頷頴穎顆題顒顎顓顏額顳顢顛顙顥纇顫顬顰顴風颺颭颮颯颶颸颼颻飀飄飆飆飛饗饜飣饑飥餳飩餼飪飫飭飯飲餞飾飽飼飿飴餌饒餉餄餎餃餏餅餑餖餓餘餒餕餜餛餡館餷饋餶餿饞饁饃餺餾饈饉饅饊饌饢馬馭馱馴馳驅馹駁驢駔駛駟駙駒騶駐駝駑駕驛駘驍罵駰驕驊駱駭駢驫驪騁驗騂駸駿騏騎騍騅騌驌驂騙騭騤騷騖驁騮騫騸驃騾驄驏驟驥驦驤髏髖髕鬢魘魎魚魛魢魷魨魯魴魺鮁鮃鯰鱸鮋鮓鮒鮊鮑鱟鮍鮐鮭鮚鮳鮪鮞鮦鰂鮜鱠鱭鮫鮮鮺鯗鱘鯁鱺鰱鰹鯉鰣鰷鯀鯊鯇鮶鯽鯒鯖鯪鯕鯫鯡鯤鯧鯝鯢鯰鯛鯨鯵鯴鯔鱝鰈鰏鱨鯷鰮鰃鰓鱷鰍鰒鰉鰁鱂鯿鰠鼇鰭鰨鰥鰩鰟鰜鰳鰾鱈鱉鰻鰵鱅鰼鱖鱔鱗鱒鱯鱤鱧鱣鳥鳩雞鳶鳴鳲鷗鴉鶬鴇鴆鴣鶇鸕鴨鴞鴦鴒鴟鴝鴛鴬鴕鷥鷙鴯鴰鵂鴴鵃鴿鸞鴻鵐鵓鸝鵑鵠鵝鵒鷳鵜鵡鵲鶓鵪鶤鵯鵬鵮鶉鶊鵷鷫鶘鶡鶚鶻鶿鶥鶩鷊鷂鶲鶹鶺鷁鶼鶴鷖鸚鷓鷚鷯鷦鷲鷸鷺鸇鷹鸌鸏鸛鸘鹺麥麩黃黌黶黷黲黽龍歷誌製壹臺臯準復勐鐘註範籤' + let s2tMap = null + let t2sMap = null + const getMaps = () => { + if (!s2tMap) { + const ss = JTPYStr() + const tt = FTPYStr() + s2tMap = new Map() + t2sMap = new Map() + for (let i = 0; i < ss.length; i++) { + s2tMap.set(ss[i], tt[i]) + t2sMap.set(tt[i], ss[i]) + } + } + return { s2tMap, t2sMap } + } + const Traditionalized = cc => { + const { s2tMap } = getMaps() let str = '' - const ss = JTPYStr() - const tt = FTPYStr() for (let i = 0; i < cc.length; i++) { - if (cc.charCodeAt(i) > 10000 && ss.indexOf(cc.charAt(i)) !== -1) { - str += tt.charAt(ss.indexOf(cc.charAt(i))) - } else str += cc.charAt(i) + const ch = cc.charAt(i) + str += cc.charCodeAt(i) > 10000 && s2tMap.has(ch) ? s2tMap.get(ch) : ch } return str } const Simplized = cc => { + const { t2sMap } = getMaps() let str = '' - const ss = JTPYStr() - const tt = FTPYStr() for (let i = 0; i < cc.length; i++) { - if (cc.charCodeAt(i) > 10000 && tt.indexOf(cc.charAt(i)) !== -1) { - str += ss.charAt(tt.indexOf(cc.charAt(i))) - } else str += cc.charAt(i) + const ch = cc.charAt(i) + str += cc.charCodeAt(i) > 10000 && t2sMap.has(ch) ? t2sMap.get(ch) : ch } return str } const translateInitialization = () => { + translateButtonObject = document.getElementById('translateLink') if (translateButtonObject) { if (currentEncoding !== targetEncoding) { translateButtonObject.textContent = diff --git a/source/js/utils.js b/source/js/utils.js index 4e44996..e60c86e 100644 --- a/source/js/utils.js +++ b/source/js/utils.js @@ -45,29 +45,55 @@ } }, - overflowPaddingR: { - add: () => { - const paddingRight = window.innerWidth - document.body.clientWidth - - if (paddingRight > 0) { - document.body.style.paddingRight = `${paddingRight}px` - document.body.style.overflow = 'hidden' - const menuElement = document.querySelector('#page-header.nav-fixed #menus') - if (menuElement) { - menuElement.style.paddingRight = `${paddingRight}px` - } - } - }, - remove: () => { - document.body.style.paddingRight = '' - document.body.style.overflow = '' - const menuElement = document.querySelector('#page-header.nav-fixed #menus') - if (menuElement) { - menuElement.style.paddingRight = '' - } + rafThrottle: fn => { + let rafId = null + return (...args) => { + if (rafId) return + rafId = requestAnimationFrame(() => { + fn(...args) + rafId = null + }) } }, + overflowPaddingR: (() => { + let headerElement = null + let menuElement = null + + const getElements = () => { + if (!headerElement) { + headerElement = document.getElementById('page-header') + } + if (!menuElement) { + menuElement = document.getElementById('menus') + } + return { headerElement, menuElement } + } + + return { + add: () => { + const paddingRight = window.innerWidth - document.body.clientWidth + + if (paddingRight > 0) { + document.body.style.paddingRight = `${paddingRight}px` + document.body.style.overflow = 'hidden' + const { headerElement: header, menuElement: menu } = getElements() + if (header && menu && header.classList.contains('nav-fixed')) { + menu.style.paddingRight = `${paddingRight}px` + } + } + }, + remove: () => { + document.body.style.paddingRight = '' + document.body.style.overflow = '' + const { headerElement: header, menuElement: menu } = getElements() + if (header && menu && header.classList.contains('nav-fixed')) { + menu.style.paddingRight = '' + } + } + } + })(), + snackbarShow: (text, showAction = false, duration = 2000) => { const { position, bgLight, bgDark } = GLOBAL_CONFIG.Snackbar const bg = document.documentElement.getAttribute('data-theme') === 'light' ? bgLight : bgDark @@ -133,7 +159,8 @@ const animate = currentTime => { const timeElapsed = currentTime - startTime const progress = Math.min(timeElapsed / time, 1) - window.scrollTo(0, currentPos + (pos - currentPos) * progress) + const easedProgress = 1 - Math.pow(1 - progress, 4) // easeOutQuart + window.scrollTo(0, currentPos + (pos - currentPos) * easedProgress) if (progress < 1) { requestAnimationFrame(animate) }