improvement

This commit is contained in:
myw
2026-07-14 14:41:41 +08:00 Unverified
parent f4a6f79142
commit 37233ab28b
18 changed files with 1718 additions and 1660 deletions
+125 -148
View File
@@ -54,6 +54,8 @@
(() => { (() => {
const limitConfig = !{ JSON.stringify(page.limit || {}) } const limitConfig = !{ JSON.stringify(page.limit || {}) }
const escapeHtml = str => String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
const sortDataByDate = data => data.sort((a, b) => new Date(b.date) - new Date(a.date)) const sortDataByDate = data => data.sort((a, b) => new Date(b.date) - new Date(a.date))
const filterDataByLimit = (data, limit) => { const filterDataByLimit = (data, limit) => {
@@ -64,13 +66,10 @@
return data.filter(item => new Date(item.date) >= limitDate) return data.filter(item => new Date(item.date) >= limitDate)
} }
return data return data
}; }
const formatToTimeZone = (date) => { const dateFormatter = new Intl.DateTimeFormat('en-GB', {
const fullDate = date.length === 10 ? `${date} 00:00:00` : date timeZone: !{JSON.stringify(config.timezone || '')} || Intl.DateTimeFormat().resolvedOptions().timeZone,
const visitorTimeZone = '#{config.timezone}' || Intl.DateTimeFormat().resolvedOptions().timeZone
const options = {
timeZone: visitorTimeZone,
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
@@ -78,35 +77,36 @@
minute: '2-digit', minute: '2-digit',
second: '2-digit', second: '2-digit',
hour12: false hour12: false
} })
const [day, month, year, hour, minute, second] = new Intl.DateTimeFormat('en-GB', options)
.format(new Date(fullDate)) const formatToTimeZone = (date) => {
.match(/\d+/g) const fullDate = date.length === 10 ? `${date} 00:00:00` : date
const [day, month, year, hour, minute, second] = dateFormatter.format(new Date(fullDate)).match(/\d+/g)
return `${year}-${month}-${day} ${hour}:${minute}:${second}` return `${year}-${month}-${day} ${hour}:${minute}:${second}`
} }
const addLazyload = str => { const addLazyload = str => {
const config = { const lazyConfig = {
enable: !{Boolean(enable)}, enable: !{Boolean(enable)},
native: !{Boolean(native)}, native: !{Boolean(native)},
field: '!{field}', field: !{JSON.stringify(field || '')},
placeholder: '!{url_for(placeholder)}', placeholder: !{JSON.stringify(url_for(placeholder))},
} }
if (!config.enable || config.field !== 'site') return str if (!lazyConfig.enable || lazyConfig.field !== 'site' || str.indexOf('<img') === -1) return str
const parser = new DOMParser() const parser = new DOMParser()
const doc = parser.parseFromString(str, 'text/html') const doc = parser.parseFromString(str, 'text/html')
const images = doc.querySelectorAll('img') const images = doc.querySelectorAll('img')
images.forEach(img => { images.forEach(img => {
if (config.native) { if (lazyConfig.native) {
img.setAttribute('loading', 'lazy') img.setAttribute('loading', 'lazy')
} else { } else {
const src = img.getAttribute('src') const src = img.getAttribute('src')
img.setAttribute('data-lazy-src', src) img.setAttribute('data-lazy-src', src)
if (config.placeholder) { if (lazyConfig.placeholder) {
img.setAttribute('src', config.placeholder) img.setAttribute('src', lazyConfig.placeholder)
} else { } else {
img.removeAttribute('src') img.removeAttribute('src')
} }
@@ -119,19 +119,18 @@
const itemsPerPage = 8 const itemsPerPage = 8
let totalPages = 0 let totalPages = 0
let data = [] let data = []
let inputEventsAttached = false // Flag to mark if input event listeners have been added
const renderData = (dataSlice) => { const renderData = (dataSlice) => {
const content = dataSlice.map(item => { const content = dataSlice.map(item => {
const formattedDate = formatToTimeZone(item.date) const formattedDate = formatToTimeZone(item.date)
const tags = item.tags && item.tags.map(tag => `<span class="shuoshuo-tag">${tag}</span>`).join('') || '' const tags = item.tags && item.tags.map(tag => `<span class="shuoshuo-tag">${escapeHtml(tag)}</span>`).join('') || ''
const commentButton = item.key && !{commentsJsLoad} const commentButton = item.key && !{commentsJsLoad || false}
? `<div class="shuoshuo-comment-btn" onclick="addCommentToShuoshuo(event)"> ? `<div class="shuoshuo-comment-btn" onclick="addCommentToShuoshuo(event)">
<i class="fa-solid fa-comments"></i> <i class="fa-solid fa-comments"></i>
</div>` </div>`
: '' : ''
const commentContainer = item.key const commentContainer = item.key
? `<div class="shuoshuo-comment no-comment" data-key="${item.key}"></div>` ? `<div class="shuoshuo-comment no-comment" data-key="${escapeHtml(item.key)}"></div>`
: '' : ''
return ` return `
@@ -139,10 +138,10 @@
<div class="container"> <div class="container">
<div class="shuoshuo-item-header"> <div class="shuoshuo-item-header">
<div class="shuoshuo-avatar"> <div class="shuoshuo-avatar">
<img class="no-lightbox" src="${item.avatar || '!{url_for(theme.avatar.img)}'}"> <img class="no-lightbox" src="${item.avatar ? escapeHtml(item.avatar) : !{JSON.stringify(url_for(theme.avatar.img))}}">
</div> </div>
<div class="shuoshuo-info"> <div class="shuoshuo-info">
<div class="shuoshuo-author">${item.author || '!{config.author}'}</div> <div class="shuoshuo-author">${item.author ? escapeHtml(item.author) : !{JSON.stringify(config.author || '')}}</div>
<time class="shuoshuo-date" title="${formattedDate}"> <time class="shuoshuo-date" title="${formattedDate}">
${btf.diffDate(formattedDate, true)} ${btf.diffDate(formattedDate, true)}
</time> </time>
@@ -165,70 +164,94 @@
btf.loadLightbox(document.querySelectorAll('#article-container img:not(.no-lightbox)')) btf.loadLightbox(document.querySelectorAll('#article-container img:not(.no-lightbox)'))
} }
const renderNavigation = () => { const setupNavEvents = (nav) => {
const container = document.getElementById('article-container') nav.querySelector('.shuoshuo-prev-btn').addEventListener('click', () => {
const existingNav = container.nextElementSibling if (currentPage > 1) { currentPage--; renderPage(currentPage) }
if (existingNav && existingNav.classList.contains('shuoshuo-navigation')) { })
existingNav.remove() 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')
let nav = container.nextElementSibling
const pageInfoTemplate = '#{__('pagination.page_info')}' const pageInfoTemplate = '#{__('pagination.page_info')}'
const pageInfoText = pageInfoTemplate const pageInfoText = pageInfoTemplate
.replace(/\$\{current}/g, currentPage) .replace(/\$\{current}/g, currentPage)
.replace(/\$\{total}/g, totalPages) .replace(/\$\{total}/g, totalPages)
const navHtml = ` if (!nav || !nav.classList.contains('shuoshuo-navigation')) {
<div class="shuoshuo-navigation"> nav = document.createElement('div')
<button onclick="window.shuoshuoPrevPage()" ${currentPage === 1 ? 'disabled' : ''}><i class="fa-solid fa-chevron-left"></i></button> nav.className = 'shuoshuo-navigation'
<span class="shuoshuo-page-info">${pageInfoText}</span> nav.innerHTML = `
<input type="number" class="shuoshuo-page-input" min="1" max="${totalPages}" placeholder="${currentPage}" onkeydown="window.shuoshuoHandleKeyDown(event)"> <button class="shuoshuo-prev-btn"><i class="fa-solid fa-chevron-left"></i></button>
<button onclick="window.shuoshuoNextPage()" ${currentPage === totalPages ? 'disabled' : ''}><i class="fa-solid fa-chevron-right"></i></button> <span class="shuoshuo-page-info"></span>
</div> <input type="number" class="shuoshuo-page-input" min="1">
<button class="shuoshuo-next-btn"><i class="fa-solid fa-chevron-right"></i></button>
` `
container.insertAdjacentHTML('afterend', navHtml) container.insertAdjacentElement('afterend', nav)
setupNavEvents(nav)
// 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 nav.querySelector('.shuoshuo-page-info').textContent = pageInfoText
if (wasInvalid) { nav.querySelector('.shuoshuo-prev-btn').disabled = currentPage === 1
event.target.classList.add('invalid') nav.querySelector('.shuoshuo-next-btn').disabled = currentPage === totalPages
setTimeout(() => { const input = nav.querySelector('.shuoshuo-page-input')
event.target.classList.remove('invalid') input.max = totalPages
}, 500) input.placeholder = currentPage
}
})
inputEventsAttached = true // Mark that event listeners have been added
}
}, 0)
}
} }
const renderPage = (page) => { const renderPage = (page) => {
@@ -239,79 +262,14 @@
renderNavigation() 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 loadShuoshuo = async () => {
const container = document.getElementById('article-container')
try { try {
let originData = [] let originData = []
if (!{Boolean(page.shuoshuo_url)}) { if (!{Boolean(page.shuoshuo_url)}) {
container.innerHTML = '<div class="shuoshuo-loading"><i class="fa-solid fa-circle-notch fa-spin"></i></div>'
const response = await fetch('!{url_for(page.shuoshuo_url)}') const response = await fetch('!{url_for(page.shuoshuo_url)}')
if (!response.ok) throw new Error(`HTTP ${response.status}`)
originData = await response.json() originData = await response.json()
} else { } else {
const dataElement = document.getElementById('shuoshuo-data') const dataElement = document.getElementById('shuoshuo-data')
@@ -319,14 +277,33 @@
} }
data = filterDataByLimit(sortDataByDate(originData), limitConfig) data = filterDataByLimit(sortDataByDate(originData), limitConfig)
totalPages = Math.ceil(data.length / itemsPerPage) totalPages = Math.ceil(data.length / itemsPerPage)
if (data.length === 0) {
container.innerHTML = '<div class="shuoshuo-empty"></div>'
return
}
renderPage(currentPage) renderPage(currentPage)
} catch (error) { } catch (error) {
console.error(error) console.error(error)
container.innerHTML = '<div class="shuoshuo-error"><i class="fa-solid fa-circle-exclamation"></i></div>'
}
} }
};
window.pjax ? loadShuoshuo() : window.addEventListener('load', loadShuoshuo) 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)
}
})() })()
+11 -5
View File
@@ -6,10 +6,14 @@ script.
(window.Chatra.q = window.Chatra.q || []).push(arguments) (window.Chatra.q = window.Chatra.q || []).push(arguments)
} }
btf.getScript('https://call.chatra.io/chatra.js').then(() => {
const isChatBtn = !{theme.chat.rightside_button} const isChatBtn = !{theme.chat.rightside_button}
const isChatHideShow = !{theme.chat.button_hide_show} const isChatHideShow = !{theme.chat.button_hide_show}
if (isChatBtn) {
window.ChatraSetup = { startHidden: true }
}
btf.getScript('https://call.chatra.io/chatra.js').then(() => {
if (isChatBtn) { if (isChatBtn) {
const close = () => { const close = () => {
Chatra('minimizeWidget') Chatra('minimizeWidget')
@@ -21,11 +25,13 @@ script.
Chatra('show') Chatra('show')
} }
window.ChatraSetup = { startHidden: true } window.chatBtnFn = () => {
const el = document.getElementById('chatra')
return el && el.classList.contains('chatra--expanded') ? close() : open()
}
window.chatBtnFn = () => document.getElementById('chatra').classList.contains('chatra--expanded') ? close() : open() const chatBtn = document.getElementById('chat-btn')
if (chatBtn) chatBtn.style.display = 'block'
document.getElementById('chat-btn').style.display = 'block'
} else if (isChatHideShow) { } else if (isChatHideShow) {
window.chatBtn = { window.chatBtn = {
hide: () => Chatra('hide'), hide: () => Chatra('hide'),
+2 -1
View File
@@ -21,7 +21,8 @@ script.
window.chatBtnFn = () => $crisp.is("chat:visible") ? close() : open() 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) { } else if (isChatHideShow) {
window.chatBtn = { window.chatBtn = {
hide: () => $crisp.push(["do", "chat:hide"]), hide: () => $crisp.push(["do", "chat:hide"]),
+2 -2
View File
@@ -32,8 +32,8 @@ script.
isShow ? close() : open() 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) { } else if (isChatHideShow) {
window.chatBtn = { window.chatBtn = {
hide: () => window.tidioChatApi && window.tidioChatApi.hide(), hide: () => window.tidioChatApi && window.tidioChatApi.hide(),
+1 -1
View File
@@ -3,7 +3,7 @@
script. script.
(() => { (() => {
const isShuoshuo = GLOBAL_CONFIG_SITE.pageType === 'shuoshuo'== 'shuoshuo' const isShuoshuo = GLOBAL_CONFIG_SITE.pageType === 'shuoshuo'
const dqOption = !{JSON.stringify(dqOption)} const dqOption = !{JSON.stringify(dqOption)}
const destroyDisqusjs = () => { const destroyDisqusjs = () => {
+1 -1
View File
@@ -3,7 +3,7 @@
script. script.
(()=>{ (()=>{
const isShuoshuo = GLOBAL_CONFIG_SITE.pageType === 'shuoshuo'== 'shuoshuo' const isShuoshuo = GLOBAL_CONFIG_SITE.pageType === 'shuoshuo'
const loadFBComment = (el = document, path) => { const loadFBComment = (el = document, path) => {
if (isShuoshuo) { if (isShuoshuo) {
+20 -17
View File
@@ -2,7 +2,22 @@
- const { tags, enableMenu } = theme.math.mathjax - const { tags, enableMenu } = theme.math.mathjax
script. 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 loadMathjax = () => {
const article = document.getElementById('article-container')
if (!article) return
changeScriptToMath(article)
if (!window.MathJax) { if (!window.MathJax) {
window.MathJax = { window.MathJax = {
loader: { loader: {
@@ -11,7 +26,8 @@ script.
//- '[tex]/bbm', //- '[tex]/bbm',
//- '[tex]/bboldx', //- '[tex]/bboldx',
//- '[tex]/dsfont', //- '[tex]/dsfont',
'[tex]/mhchem' '[tex]/mhchem',
'ui/lazy'
], ],
paths: { paths: {
'mathjax-newcm': '[mathjax]/../@mathjax/mathjax-newcm-font', 'mathjax-newcm': '[mathjax]/../@mathjax/mathjax-newcm-font',
@@ -39,25 +55,13 @@ script.
scale: 1.1 scale: 1.1
}, },
options: { options: {
lazyMargin: '200px',
enableMenu: !{enableMenu}, enableMenu: !{enableMenu},
menuOptions: { menuOptions: {
settings: { settings: {
enrich: false // Turn off Braille and voice narration text automatic generation 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 script.async = true
document.head.appendChild(script) document.head.appendChild(script)
} else { } else {
MathJax.startup.document.state(0) MathJax.typesetClear()
MathJax.texReset() MathJax.typesetPromise([ article ])
MathJax.typesetPromise()
} }
} }
+132 -94
View File
@@ -51,7 +51,7 @@ script.
clone.setAttribute('viewBox', initViewBox.join(' ')) clone.setAttribute('viewBox', initViewBox.join(' '))
} }
if (!clone.getAttribute('xmlns')) clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg') 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') clone.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink')
} }
// inject background to match current theme // inject background to match current theme
@@ -70,7 +70,8 @@ script.
const blob = new Blob([htmlSource], { type: 'text/html;charset=utf-8' }) const blob = new Blob([htmlSource], { type: 'text/html;charset=utf-8' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
window.open(url, '_blank', 'noopener') window.open(url, '_blank', 'noopener')
setTimeout(() => URL.revokeObjectURL(url), 30000)
setTimeout(() => URL.revokeObjectURL(url), 5000)
} }
const attachMermaidViewerButton = wrap => { const attachMermaidViewerButton = wrap => {
@@ -91,10 +92,6 @@ script.
const svg = wrap.__mermaidOriginalSvg || wrap.querySelector('svg') const svg = wrap.__mermaidOriginalSvg || wrap.querySelector('svg')
if (!svg) return if (!svg) return
const initViewBox = wrap.__mermaidInitViewBox const initViewBox = wrap.__mermaidInitViewBox
if (typeof svg === 'string') {
openSvgInNewTab({ source: svg, initViewBox })
return
}
openSvgInNewTab({ source: svg, initViewBox }) openSvgInNewTab({ source: svg, initViewBox })
}) })
btn.__mermaidViewerBound = true btn.__mermaidViewerBound = true
@@ -111,6 +108,13 @@ script.
} }
const initMermaidGestures = wrap => { 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') const svg = wrap.querySelector('svg')
if (!svg) return if (!svg) return
@@ -119,158 +123,177 @@ script.
wrap.__mermaidInitViewBox = initVb wrap.__mermaidInitViewBox = initVb
wrap.__mermaidCurViewBox = initVb.slice() wrap.__mermaidCurViewBox = initVb.slice()
setSvgViewBox(svg, initVb) 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 // Cache BoundingClientRect, throttled on scroll to reduce reflow
if (wrap.__mermaidGestureBound) return let cachedRect = svg.getBoundingClientRect()
wrap.__mermaidGestureBound = true 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 // Precompute clamp bounds from initial viewBox
const clientToViewBox = (clientX, clientY) => { const minW = initVb[2] * 0.1
const rect = svg.getBoundingClientRect() const maxW = initVb[2] * 10
const vb = wrap.__mermaidCurViewBox || getSvgViewBox(svg) const minH = initVb[3] * 0.1
const x = vb[0] + (clientX - rect.left) * (vb[2] / rect.width) const maxH = initVb[3] * 10
const y = vb[1] + (clientY - rect.top) * (vb[3] / rect.height) const clampVb = vb => {
return { x, y, rect, 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 = { const state = {
pointers: new Map(), pointers: new Map(),
startVb: null, startVb: null,
startDist: 0, startDist: 0,
startCenter: null lastPointerX: 0,
} lastPointerY: 0
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)
} }
const onPointerDown = e => { const onPointerDown = e => {
// Allow only primary button for mouse
if (e.pointerType === 'mouse' && e.button !== 0) return if (e.pointerType === 'mouse' && e.button !== 0) return
svg.setPointerCapture(e.pointerId) svg.setPointerCapture(e.pointerId)
const curVb = wrap.__mermaidCurViewBox
state.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }) state.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
if (state.pointers.size === 1) { 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) { } else if (state.pointers.size === 2) {
const pts = [...state.pointers.values()] const pts = [...state.pointers.values()]
const dx = pts[0].x - pts[1].x const dx = pts[0].x - pts[1].x
const dy = pts[0].y - pts[1].y const dy = pts[0].y - pts[1].y
state.startDist = Math.hypot(dx, dy) state.startDist = Math.hypot(dx, dy)
state.startVb = (wrap.__mermaidCurViewBox || getSvgViewBox(svg)).slice() state.startVb = curVb.slice()
state.startCenter = { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 }
} }
} }
const onPointerMove = e => { const onPointerMove = e => {
if (!state.pointers.has(e.pointerId)) return if (!state.pointers.has(e.pointerId)) return
state.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }) state.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
const curVb = wrap.__mermaidCurViewBox
// Pan with 1 pointer const rect = getRect()
if (state.pointers.size === 1 && state.startVb) { if (state.pointers.size === 1 && state.startVb) {
const p = [...state.pointers.values()][0] const p = state.pointers.values().next().value
const prev = { x: e.clientX - e.movementX, y: e.clientY - e.movementY } const dxClient = p.x - state.lastPointerX
// movementX/Y unreliable on touch, compute from stored last position const dyClient = p.y - state.lastPointerY
const last = wrap.__mermaidLastSinglePointer || p state.lastPointerX = p.x
const dxClient = p.x - last.x state.lastPointerY = p.y
const dyClient = p.y - last.y const dx = dxClient * (curVb[2] / rect.width)
wrap.__mermaidLastSinglePointer = p const dy = dyClient * (curVb[3] / rect.height)
setCurVb([curVb[0] - dx, curVb[1] - dy, curVb[2], curVb[3]])
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]])
return return
} }
// Pinch zoom with 2 pointers
if (state.pointers.size === 2 && state.startVb && state.startDist > 0) { if (state.pointers.size === 2 && state.startVb && state.startDist > 0) {
const pts = [...state.pointers.values()] const pts = [...state.pointers.values()]
const dx = pts[0].x - pts[1].x const dx = pts[0].x - pts[1].x
const dy = pts[0].y - pts[1].y const dy = pts[0].y - pts[1].y
const dist = Math.hypot(dx, dy) const dist = Math.hypot(dx, dy)
if (!dist) return 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 cx = (pts[0].x + pts[1].x) / 2
const cy = (pts[0].y + pts[1].y) / 2 const cy = (pts[0].y + pts[1].y) / 2
const centerClient = { x: cx, y: cy } const px = curVb[0] + (cx - rect.left) * (curVb[2] / rect.width)
const py = curVb[1] + (cy - rect.top) * (curVb[3] / rect.height)
const pxy = clientToViewBox(centerClient.x, centerClient.y) setCurVb(zoomAtPoint(state.startVb, factor, px, py))
const cpx = pxy.x
const cpy = pxy.y
const vb = zoomAtPoint(state.startVb, factor, cpx, cpy)
setCurVb(vb)
} }
} }
const onPointerUpOrCancel = e => { 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) state.pointers.delete(e.pointerId)
if (state.pointers.size === 0) { if (state.pointers.size === 0) {
state.startVb = null state.startVb = null
state.startDist = 0 state.startDist = 0
state.startCenter = null
wrap.__mermaidLastSinglePointer = null
} else if (state.pointers.size === 1) { } else if (state.pointers.size === 1) {
// reset single pointer baseline to avoid jump const p = state.pointers.values().next().value
wrap.__mermaidLastSinglePointer = [...state.pointers.values()][0] state.lastPointerX = p.x
state.lastPointerY = p.y
} }
} }
// Wheel zoom (mouse/trackpad)
const onWheel = e => { const onWheel = e => {
// ctrlKey on mac trackpad pinch; we treat both as zoom // Prevent event bubbling from triggering external scroll
e.preventDefault() e.preventDefault()
const delta = e.deltaY e.stopPropagation()
const zoomFactor = delta > 0 ? 1.1 : 0.9 // Normalize deltaY across deltaMode (Chrome uses pixels, Safari uses lines)
const { x, y } = clientToViewBox(e.clientX, e.clientY) let delta = e.deltaY
const vb = (wrap.__mermaidCurViewBox || getSvgViewBox(svg)).slice() if (e.deltaMode === 1) delta *= 16
setCurVb(zoomAtPoint(vb, zoomFactor, x, y)) 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 onDblClick = () => {
const init = wrap.__mermaidInitViewBox const init = wrap.__mermaidInitViewBox
if (!init) return if (init) setCurVb(init)
wrap.__mermaidCurViewBox = init.slice()
setSvgViewBox(svg, init)
} }
svg.addEventListener('pointerdown', onPointerDown) svg.addEventListener('pointerdown', onPointerDown, { signal: ac.signal })
svg.addEventListener('pointermove', onPointerMove) svg.addEventListener('pointermove', onPointerMove, { signal: ac.signal })
svg.addEventListener('pointerup', onPointerUpOrCancel) svg.addEventListener('pointerup', onPointerUpOrCancel, { signal: ac.signal })
svg.addEventListener('pointercancel', onPointerUpOrCancel) svg.addEventListener('pointercancel', onPointerUpOrCancel, { signal: ac.signal })
svg.addEventListener('wheel', onWheel, { passive: false }) svg.addEventListener('wheel', onWheel, { passive: false, signal: ac.signal })
svg.addEventListener('dblclick', onDblClick) svg.addEventListener('dblclick', onDblClick, { signal: ac.signal })
} }
const runMermaid = ele => { const runMermaid = ele => {
window.loadMermaid = true window.loadMermaid = true
const theme = document.documentElement.getAttribute('data-theme') === 'dark' ? '!{theme.mermaid.theme.dark}' : '!{theme.mermaid.theme.light}' const theme = document.documentElement.getAttribute('data-theme') === 'dark' ? '!{theme.mermaid.theme.dark}' : '!{theme.mermaid.theme.light}'
ele.forEach((item, index) => { ele.forEach((item, index) => {
const mermaidSrc = item.firstElementChild const mermaidSrc = item.firstElementChild
// Clean up event listeners before removing old SVG
// Clear old render (themeChange/pjax will rerun) if (item.__mermaidAbortController) {
item.__mermaidAbortController.abort()
}
const oldSvg = item.querySelector('svg') const oldSvg = item.querySelector('svg')
if (oldSvg) oldSvg.remove() if (oldSvg) oldSvg.remove()
item.__mermaidGestureBound = false let config = {}
try {
const config = mermaidSrc.dataset.config ? JSON.parse(mermaidSrc.dataset.config) : {} config = mermaidSrc.dataset.config ? JSON.parse(mermaidSrc.dataset.config) : {}
} catch (e) {
console.warn('[mermaid] failed to parse dataset.config:', e)
}
if (!config.theme) { if (!config.theme) {
config.theme = theme config.theme = theme
} }
@@ -278,7 +301,6 @@ script.
const mermaidID = `mermaid-${index}` const mermaidID = `mermaid-${index}`
const mermaidDefinition = mermaidThemeConfig + mermaidSrc.textContent const mermaidDefinition = mermaidThemeConfig + mermaidSrc.textContent
const renderFn = mermaid.render(mermaidID, mermaidDefinition)
const renderMermaid = svg => { const renderMermaid = svg => {
mermaidSrc.insertAdjacentHTML('afterend', svg) mermaidSrc.insertAdjacentHTML('afterend', svg)
if (!{theme.mermaid.zoom_pan}) initMermaidGestures(item) if (!{theme.mermaid.zoom_pan}) initMermaidGestures(item)
@@ -286,9 +308,25 @@ script.
if (!{theme.mermaid.open_in_new_tab}) attachMermaidViewerButton(item) 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)
}
try {
const renderFn = mermaid.render(mermaidID, mermaidDefinition)
// mermaid v9 and v10 compatibility // mermaid v9 and v10 compatibility
typeof renderFn === 'string' ? renderMermaid(renderFn) : renderFn.then(({ svg }) => renderMermaid(svg)) if (typeof renderFn === 'string') {
renderMermaid(renderFn)
} else {
renderFn.then(({ svg }) => renderMermaid(svg)).catch(handleError)
}
} catch (err) {
handleError(err)
}
}) })
} }
+4 -1
View File
@@ -53,8 +53,9 @@ script.
document.addEventListener('pjax:complete', () => { document.addEventListener('pjax:complete', () => {
btf.removeGlobalFnEvent('pjaxCompleteOnce') btf.removeGlobalFnEvent('pjaxCompleteOnce')
document.querySelectorAll('script[data-pjax]').forEach(item => { document.querySelectorAll('script[data-pjax]').forEach(item => {
if (!item.parentNode) return
const newScript = document.createElement('script') 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)) Array.from(item.attributes).forEach(attr => newScript.setAttribute(attr.name, attr.value))
newScript.appendChild(document.createTextNode(content)) newScript.appendChild(document.createTextNode(content))
item.parentNode.replaceChild(newScript, item) item.parentNode.replaceChild(newScript, item)
@@ -68,6 +69,8 @@ script.
!{theme.error_404 && theme.error_404.enable} !{theme.error_404 && theme.error_404.enable}
? pjax.loadUrl('!{url_for("/404.html")}') ? pjax.loadUrl('!{url_for("/404.html")}')
: window.location.href = e.request.responseURL : window.location.href = e.request.responseURL
} else {
window.location.href = e.request.responseURL
} }
}) })
}) })
+1 -1
View File
@@ -21,7 +21,7 @@ const lazyload = htmlContent => {
// Handle src attributes with double quotes, single quotes, or no quotes (unified approach) // 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) // Matches: src="..." or src='...' or src=... (e.g., after minification by hexo-minify)
return htmlContent.replace(/(<img(?![^>]*?\bdata-lazy-src=)(?:\s[^>]*?)?\ssrc=)(?:"([^"]*)"|'([^']*)'|([^\s>]+))(?![^<]*<\/script>)/gi, (match, prefix, srcDoubleQuote, srcSingleQuote, srcNoQuote) => { return htmlContent.replace(/(<img(?![^>]*?\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}"` return `${prefix}"${bg}" data-lazy-src="${src}"`
}) })
} }
+51 -32
View File
@@ -6,33 +6,46 @@
hexo.extend.generator.register('post', locals => { hexo.extend.generator.register('post', locals => {
const imgTestReg = /\.(png|jpe?g|gif|svg|webp|avif)(\?.*)?$/i 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 { post_asset_folder: postAssetFolder } = hexo.config
const { cover: { default_cover: defaultCover } } = hexo.theme.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 () { function * createCoverGenerator () {
if (!defaultCover) { if (!defaultCover || (Array.isArray(defaultCover) && defaultCover.length === 0)) {
while (true) yield false while (true) yield false
} }
if (!Array.isArray(defaultCover)) { if (!Array.isArray(defaultCover)) {
while (true) yield defaultCover while (true) yield defaultCover
} }
const coverCount = defaultCover.length if (defaultCover.length === 1) {
if (coverCount === 1) {
while (true) yield defaultCover[0] while (true) yield defaultCover[0]
} }
const coverCount = defaultCover.length
const maxHistory = Math.min(3, coverCount - 1) const maxHistory = Math.min(3, coverCount - 1)
const history = [] const history = []
while (true) { while (true) {
let index let index
do { do {
index = Math.floor(Math.random() * coverCount) index = Math.floor(Math.random() * coverCount)
} while (history.includes(index)) } while (history.includes(index))
history.push(index) history.push(index)
if (history.length > maxHistory) history.shift()
if (history.length > maxHistory) {
history.shift()
}
yield defaultCover[index] yield defaultCover[index]
} }
@@ -40,32 +53,31 @@ hexo.extend.generator.register('post', locals => {
const coverGenerator = createCoverGenerator() 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 => { 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 (data.cover === false) return data
if (postAssetFolder) {
if (topImg && topImg.indexOf('/') === -1 && imgTestReg.test(topImg)) { if (!data.cover) {
data.top_img = `${data.path}${topImg}` data.cover = coverGenerator.next().value
}
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 (coverVal === false) return data if (isImage(data.cover)) {
// 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))) {
data.cover_type = 'img' data.cover_type = 'img'
} }
@@ -75,16 +87,23 @@ hexo.extend.generator.register('post', locals => {
const posts = locals.posts.sort('date').toArray() const posts = locals.posts.sort('date').toArray()
const { length } = posts const { length } = posts
return posts.map((post, i) => { return posts.map((post, index) => {
if (i) post.prev = posts[i - 1] const data = post
if (i < length - 1) post.next = posts[i + 1]
post.__post = true if (index > 0) {
data.prev = posts[index - 1]
}
if (index < length - 1) {
data.next = posts[index + 1]
}
data.__post = true
return { return {
data: handleImg(post), data: handleImg(data),
layout: 'post', layout: 'post',
path: post.path path: data.path
} }
}) })
}) })
+79 -27
View File
@@ -11,46 +11,87 @@ hexo.extend.helper.register('inject_head_js', function () {
const createCustomJs = () => ` const createCustomJs = () => `
const saveToLocal = { const saveToLocal = {
set: (key, value, ttl) => { set: (key, value, ttl) => {
if (!ttl) return const data = { value }
const expiry = Date.now() + ttl * 86400000
localStorage.setItem(key, JSON.stringify({ value, expiry })) if (ttl != null) {
data.expiry = Date.now() + ttl * 86400000
}
localStorage.setItem(key, JSON.stringify(data))
}, },
get: key => { get: key => {
const itemStr = localStorage.getItem(key) const itemStr = localStorage.getItem(key)
if (!itemStr) return undefined if (!itemStr) return
const { value, expiry } = JSON.parse(itemStr)
if (Date.now() > expiry) { try {
const data = JSON.parse(itemStr)
if (data.expiry && Date.now() > data.expiry) {
localStorage.removeItem(key)
return
}
return data.value
} catch {
localStorage.removeItem(key) localStorage.removeItem(key)
return undefined
} }
return value
} }
} }
const scriptCache = new Map()
const cssCache = new Map()
window.btf = { window.btf = {
saveToLocal, saveToLocal,
getScript: (url, attr = {}) => new Promise((resolve, reject) => { getScript: (url, attr = {}) => {
if (scriptCache.has(url)) {
return scriptCache.get(url)
}
const promise = new Promise((resolve, reject) => {
const script = document.createElement('script') const script = document.createElement('script')
script.src = url script.src = url
script.async = true script.async = true
Object.entries(attr).forEach(([key, val]) => script.setAttribute(key, val))
script.onload = script.onreadystatechange = () => { for (const key in attr) {
if (!script.readyState || /loaded|complete/.test(script.readyState)) resolve() script.setAttribute(key, attr[key])
} }
script.onload = resolve
script.onerror = reject script.onerror = reject
document.head.appendChild(script) document.head.appendChild(script)
}), })
getCSS: (url, id) => new Promise((resolve, reject) => {
scriptCache.set(url, promise)
return promise
},
getCSS: (url, id) => {
if (cssCache.has(url)) {
return cssCache.get(url)
}
const promise = new Promise((resolve, reject) => {
const link = document.createElement('link') const link = document.createElement('link')
link.rel = 'stylesheet' link.rel = 'stylesheet'
link.href = url link.href = url
if (id) link.id = id
link.onload = link.onreadystatechange = () => { if (id) {
if (!link.readyState || /loaded|complete/.test(link.readyState)) resolve() link.id = id
} }
link.onload = resolve
link.onerror = reject link.onerror = reject
document.head.appendChild(link) document.head.appendChild(link)
}), })
cssCache.set(url, promise)
return promise
},
addGlobalFn: (key, fn, name = false, parent = window) => { addGlobalFn: (key, fn, name = false, parent = window) => {
if (!${pjax.enable} && key.startsWith('pjax')) return if (!${pjax.enable} && key.startsWith('pjax')) return
const globalFn = parent.globalFn || {} const globalFn = parent.globalFn || {}
@@ -65,16 +106,17 @@ hexo.extend.helper.register('inject_head_js', function () {
if (!darkmode.enable) return '' if (!darkmode.enable) return ''
let darkmodeJs = ` let darkmodeJs = `
const metaThemeColor = document.querySelector('meta[name="theme-color"]')
const activateDarkMode = () => { const activateDarkMode = () => {
document.documentElement.setAttribute('data-theme', 'dark') document.documentElement.dataset.theme = 'dark'
if (document.querySelector('meta[name="theme-color"]') !== null) { if (metaThemeColor !== null) {
document.querySelector('meta[name="theme-color"]').setAttribute('content', '${themeColorDark}') metaThemeColor.setAttribute('content', '${themeColorDark}')
} }
} }
const activateLightMode = () => { const activateLightMode = () => {
document.documentElement.setAttribute('data-theme', 'light') document.documentElement.dataset.theme = 'light'
if (document.querySelector('meta[name="theme-color"]') !== null) { if (metaThemeColor !== null) {
document.querySelector('meta[name="theme-color"]').setAttribute('content', '${themeColorLight}') metaThemeColor.setAttribute('content', '${themeColorLight}')
} }
} }
@@ -95,10 +137,15 @@ hexo.extend.helper.register('inject_head_js', function () {
else if (mediaQueryDark.matches) activateDarkMode() else if (mediaQueryDark.matches) activateDarkMode()
else { else {
const hour = new Date().getHours() 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() isNight ? activateDarkMode() : activateLightMode()
} }
mediaQueryDark.addEventListener('change', () => { mediaQueryDark.addEventListener('change', e => {
if (saveToLocal.get('theme') === undefined) { if (saveToLocal.get('theme') === undefined) {
e.matches ? activateDarkMode() : activateLightMode() e.matches ? activateDarkMode() : activateLightMode()
} }
@@ -111,7 +158,12 @@ hexo.extend.helper.register('inject_head_js', function () {
case 2: case 2:
darkmodeJs += ` darkmodeJs += `
const hour = new Date().getHours() 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() if (theme === undefined) isNight ? activateDarkMode() : activateLightMode()
else theme === 'light' ? activateLightMode() : activateDarkMode() else theme === 'light' ? activateLightMode() : activateDarkMode()
` `
+148 -138
View File
@@ -2,18 +2,25 @@ document.addEventListener('DOMContentLoaded', () => {
let headerContentWidth, $nav let headerContentWidth, $nav
let mobileSidebarOpen = false let mobileSidebarOpen = false
// rightsideScrollPercent
let goUpElement = null
let scrollPercentElement = null
const adjustMenu = init => { const adjustMenu = init => {
const getAllWidth = ele => Array.from(ele).reduce((width, i) => width + i.offsetWidth, 0) let hideMenuIndex = false
if (init) { if (init) {
const blogInfoWidth = getAllWidth(document.querySelector('#blog-info > a').children) const blogInfoWidth = Array.from(document.querySelector('#blog-info > a').children).reduce((w, i) => w + i.offsetWidth, 0)
const menusWidth = getAllWidth(document.getElementById('menus').children) const menusWidth = Array.from(document.getElementById('menus').children).reduce((w, i) => w + i.offsetWidth, 0)
headerContentWidth = blogInfoWidth + menusWidth headerContentWidth = blogInfoWidth + menusWidth
$nav = document.getElementById('nav') $nav = document.getElementById('nav')
} }
const hideMenuIndex = window.innerWidth <= 768 || headerContentWidth > $nav.offsetWidth - 120 hideMenuIndex = window.innerWidth <= 768 || headerContentWidth > $nav.offsetWidth - 120
requestAnimationFrame(() => {
$nav.classList.toggle('hide-menu', hideMenuIndex) $nav.classList.toggle('hide-menu', hideMenuIndex)
})
} }
// 初始化header // 初始化header
@@ -54,7 +61,7 @@ document.addEventListener('DOMContentLoaded', () => {
* 代碼 * 代碼
* 只適用於Hexo默認的代碼渲染 * 只適用於Hexo默認的代碼渲染
*/ */
const addHighlightTool = () => { const addHighlightTool = $article => {
const highLight = GLOBAL_CONFIG.highlight const highLight = GLOBAL_CONFIG.highlight
if (!highLight) return if (!highLight) return
@@ -64,8 +71,8 @@ document.addEventListener('DOMContentLoaded', () => {
const isNotHighlightJs = plugin !== 'highlight.js' const isNotHighlightJs = plugin !== 'highlight.js'
const isPrismjs = plugin === 'prismjs' const isPrismjs = plugin === 'prismjs'
const $figureHighlight = isNotHighlightJs const $figureHighlight = isNotHighlightJs
? Array.from(document.querySelectorAll('code[class*="language-"]')).map(code => code.parentElement) ? Array.from($article.querySelectorAll('code[class*="language-"]')).map(code => code.parentElement)
: document.querySelectorAll('figure.highlight') : $article.querySelectorAll('figure.highlight')
if (!((isShowTool || highlightHeightLimit) && $figureHighlight.length)) return if (!((isShowTool || highlightHeightLimit) && $figureHighlight.length)) return
@@ -167,33 +174,23 @@ document.addEventListener('DOMContentLoaded', () => {
// 獲取隱藏狀態下元素的真實高度 // 獲取隱藏狀態下元素的真實高度
const getActualHeight = item => { const getActualHeight = item => {
if (item.offsetHeight > 0) return item.offsetHeight if (item.offsetHeight > 0) return item.offsetHeight
const hiddenElements = new Map()
const fix = () => { const clone = item.cloneNode(true)
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 style = 'visibility: hidden !important; display: block !important;' clone.style.cssText = `
hiddenElements.forEach((originalStyle, elem) => { position: absolute !important;
elem.setAttribute('style', originalStyle ? originalStyle + ';' + style : style) 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 = () => { item.parentNode.insertBefore(clone, item)
hiddenElements.forEach((originalStyle, elem) => { const height = clone.offsetHeight
if (originalStyle === '') elem.removeAttribute('style') clone.remove()
else elem.setAttribute('style', originalStyle)
})
}
fix()
const height = item.offsetHeight
restore()
return height return height
} }
@@ -244,9 +241,9 @@ document.addEventListener('DOMContentLoaded', () => {
/** /**
* PhotoFigcaption * PhotoFigcaption
*/ */
const addPhotoFigcaption = () => { const addPhotoFigcaption = $article => {
if (!GLOBAL_CONFIG.isPhotoFigcaption) return if (!GLOBAL_CONFIG.isPhotoFigcaption) return
document.querySelectorAll('#article-container img').forEach(item => { $article.querySelectorAll('img').forEach(item => {
const altValue = item.title || item.alt const altValue = item.title || item.alt
if (!altValue) return if (!altValue) return
const ele = document.createElement('div') const ele = document.createElement('div')
@@ -259,8 +256,8 @@ document.addEventListener('DOMContentLoaded', () => {
/** /**
* Lightbox * Lightbox
*/ */
const runLightbox = () => { const runLightbox = $article => {
btf.loadLightbox(document.querySelectorAll('#article-container img:not(.no-lightbox)')) btf.loadLightbox($article.querySelectorAll('img:not(.no-lightbox)'))
} }
/** /**
@@ -424,11 +421,11 @@ document.addEventListener('DOMContentLoaded', () => {
*/ */
const rightsideScrollPercent = currentTop => { const rightsideScrollPercent = currentTop => {
const scrollPercent = btf.getScrollPercent(currentTop, document.body) const scrollPercent = btf.getScrollPercent(currentTop, document.body)
const goUpElement = document.getElementById('go-up')
if (!goUpElement || !scrollPercentElement) return
if (scrollPercent < 95) { if (scrollPercent < 95) {
goUpElement.classList.add('show-percent') goUpElement.classList.add('show-percent')
goUpElement.querySelector('.scroll-percent').textContent = scrollPercent scrollPercentElement.textContent = scrollPercent
} else { } else {
goUpElement.classList.remove('show-percent') goUpElement.classList.remove('show-percent')
} }
@@ -465,7 +462,7 @@ document.addEventListener('DOMContentLoaded', () => {
} }
let flag = '' let flag = ''
const scrollTask = btf.throttle(() => { const scrollTask = btf.rafThrottle(() => {
const currentTop = window.scrollY || document.documentElement.scrollTop const currentTop = window.scrollY || document.documentElement.scrollTop
const isDown = scrollDirection(currentTop) const isDown = scrollDirection(currentTop)
if (currentTop > 56) { if (currentTop > 56) {
@@ -497,7 +494,7 @@ document.addEventListener('DOMContentLoaded', () => {
isShowPercent && rightsideScrollPercent(currentTop) isShowPercent && rightsideScrollPercent(currentTop)
checkDocumentHeight() checkDocumentHeight()
}, 300) })
btf.addEventListenerPjax(window, 'scroll', scrollTask, { passive: true }) btf.addEventListenerPjax(window, 'scroll', scrollTask, { passive: true })
} }
@@ -505,10 +502,9 @@ document.addEventListener('DOMContentLoaded', () => {
/** /**
* toc, anchor * toc, anchor
*/ */
const scrollFnToDo = () => { const scrollFnToDo = $article => {
const isToc = GLOBAL_CONFIG_SITE.isToc const isToc = GLOBAL_CONFIG_SITE.isToc
const isAnchor = GLOBAL_CONFIG.isAnchor const isAnchor = GLOBAL_CONFIG.isAnchor
const $article = document.getElementById('article-container')
if (!($article && (isToc || isAnchor))) return if (!($article && (isToc || isAnchor))) return
@@ -521,7 +517,6 @@ document.addEventListener('DOMContentLoaded', () => {
$tocPercentage = $cardTocLayout.querySelector('.toc-percentage') $tocPercentage = $cardTocLayout.querySelector('.toc-percentage')
isExpand = $cardToc.classList.contains('is-expand') isExpand = $cardToc.classList.contains('is-expand')
// toc元素點擊
const tocItemClickFn = e => { const tocItemClickFn = e => {
const target = e.target.closest('.toc-link') const target = e.target.closest('.toc-link')
if (!target) return if (!target) return
@@ -548,86 +543,90 @@ document.addEventListener('DOMContentLoaded', () => {
} }
} }
// 處理 hexo-blog-encrypt 事件
$cardToc.style.display = 'block' $cardToc.style.display = 'block'
} }
// find head position & add active class
const $articleList = $article.querySelectorAll('h1,h2,h3,h4,h5,h6') const $articleList = $article.querySelectorAll('h1,h2,h3,h4,h5,h6')
let detectItem = '' if (!$articleList.length) return
// Optimization: Cache header positions let activeTocItem = null
let headerList = [] let activeParentItems = []
const updateHeaderPositions = () => {
headerList = Array.from($articleList).map(ele => ({ const updateTocUI = currentId => {
ele, const encodedAnchor = currentId ? '#' + encodeURI(decodeURI(currentId)) : ''
top: btf.getEleTop(ele), if (isAnchor) btf.updateAnchor(encodedAnchor)
id: ele.id
})) if (!isToc) return
if (activeTocItem) activeTocItem.classList.remove('active')
activeParentItems.forEach(i => i.classList.remove('active'))
activeParentItems = []
if (!currentId) {
activeTocItem = null
return
} }
updateHeaderPositions() const targetLink = Array.from($tocLink).find(link => {
const throttledUpdate = btf.throttle(updateHeaderPositions, 200) const href = link.getAttribute('href')
btf.addEventListenerPjax(window, 'resize', throttledUpdate) if (!href) return false
return decodeURI(href).replace('#', '') === decodeURI(currentId)
})
if ('ResizeObserver' in window) { if (!targetLink) return
const observer = new ResizeObserver(throttledUpdate)
observer.observe($article)
btf.addGlobalFn('pjaxSendOnce', () => { observer.disconnect() })
}
const findHeadPosition = top => { targetLink.classList.add('active')
if (top === 0) return false activeTocItem = targetLink
setTimeout(() => autoScrollToc(targetLink), 0)
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 (detectItem === currentIndex) return
if (isAnchor) btf.updateAnchor(currentId)
detectItem = currentIndex
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) { if (!isExpand) {
let parent = currentActive.parentNode let parent = targetLink.parentNode
while (!parent.matches('.toc')) { while (!parent.matches('.toc')) {
if (parent.matches('li')) parent.classList.add('active') if (parent.matches('li')) {
parent.classList.add('active')
activeParentItems.push(parent)
}
parent = parent.parentNode parent = parent.parentNode
} }
} }
} }
}
const observerOptions = {
root: null,
rootMargin: '-60px 0px -80% 0px',
threshold: 0
} }
// main of scroll const observer = new IntersectionObserver(entries => {
const tocScrollFn = btf.throttle(() => { 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 const currentTop = window.scrollY || document.documentElement.scrollTop
if (isToc && GLOBAL_CONFIG.percent.toc) { if (isToc && GLOBAL_CONFIG.percent.toc) {
$tocPercentage.textContent = btf.getScrollPercent(currentTop, $article) $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 => { const handleThemeChange = mode => {
@@ -804,8 +803,8 @@ document.addEventListener('DOMContentLoaded', () => {
/** /**
* table overflow * table overflow
*/ */
const addTableWrap = () => { const addTableWrap = $article => {
const $table = document.querySelectorAll('#article-container table') const $table = $article.querySelectorAll('table')
if (!$table.length) return if (!$table.length) return
$table.forEach(item => { $table.forEach(item => {
@@ -815,52 +814,54 @@ document.addEventListener('DOMContentLoaded', () => {
}) })
} }
/** const clickFnOfTagHide = $article => {
* tag-hide const hideButtons = $article.querySelectorAll('.hide-button')
*/
const clickFnOfTagHide = () => {
const hideButtons = document.querySelectorAll('#article-container .hide-button')
if (!hideButtons.length) return if (!hideButtons.length) return
hideButtons.forEach(item => item.addEventListener('click', e => {
const currentTarget = e.currentTarget const handleClickOfTagHide = e => {
currentTarget.classList.add('open') const button = e.target.closest('.hide-button')
addJustifiedGallery(currentTarget.nextElementSibling.querySelectorAll('.gallery-container')) if (!button) return
}, { once: true })) button.classList.add('open')
addJustifiedGallery(button.nextElementSibling.querySelectorAll('.gallery-container'))
} }
const tabsFn = () => { btf.addEventListenerPjax($article, 'click', handleClickOfTagHide)
const navTabsElements = document.querySelectorAll('#article-container .tabs') }
if (!navTabsElements.length) return
const tabsFn = $article => {
if (!$article.querySelector('.tabs')) return
const setActiveClass = (elements, activeIndex) => { const setActiveClass = (elements, activeIndex) => {
elements.forEach((el, index) => { elements.forEach((el, index) => el.classList.toggle('active', index === activeIndex))
el.classList.toggle('active', index === activeIndex)
})
} }
const handleNavClick = e => { const handleClick = e => {
const tabsRoot = e.target.closest('.tabs')
if (!tabsRoot) return
const navContainer = tabsRoot.firstElementChild
const toTopContainer = tabsRoot.lastElementChild
if (navContainer.contains(e.target)) {
const target = e.target.closest('button') const target = e.target.closest('button')
if (!target || target.classList.contains('active')) return if (!target || target.classList.contains('active')) return
const navItems = [...e.currentTarget.children] const navItems = [...navContainer.children]
const tabContents = [...e.currentTarget.nextElementSibling.children] const tabContents = [...navContainer.nextElementSibling.children]
const indexOfButton = navItems.indexOf(target) const indexOfButton = navItems.indexOf(target)
setActiveClass(navItems, indexOfButton) setActiveClass(navItems, indexOfButton)
e.currentTarget.classList.remove('no-default') navContainer.classList.remove('no-default')
setActiveClass(tabContents, indexOfButton) setActiveClass(tabContents, indexOfButton)
addJustifiedGallery(tabContents[indexOfButton].querySelectorAll('.gallery-container'), true) addJustifiedGallery(tabContents[indexOfButton].querySelectorAll('.gallery-container'), true)
return
} }
const handleToTopClick = tabElement => e => { if (toTopContainer.contains(e.target) && e.target.closest('button')) {
if (e.target.closest('button')) { btf.scrollToDest(btf.getEleTop(tabsRoot), 300)
btf.scrollToDest(btf.getEleTop(tabElement), 300)
} }
} }
navTabsElements.forEach(tabElement => { btf.addEventListenerPjax($article, 'click', handleClick)
btf.addEventListenerPjax(tabElement.firstElementChild, 'click', handleNavClick)
btf.addEventListenerPjax(tabElement.lastElementChild, 'click', handleToTopClick(tabElement))
})
} }
const toggleCardCategory = () => { const toggleCardCategory = () => {
@@ -926,10 +927,13 @@ document.addEventListener('DOMContentLoaded', () => {
} }
const unRefreshFn = () => { const unRefreshFn = () => {
window.addEventListener('resize', () => { const resizeHandler = btf.rafThrottle(() => {
adjustMenu(false) 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') const menuMask = document.getElementById('menu-mask')
menuMask && menuMask.addEventListener('click', () => { sidebarFn.close() }) menuMask && menuMask.addEventListener('click', () => { sidebarFn.close() })
@@ -947,18 +951,24 @@ document.addEventListener('DOMContentLoaded', () => {
} }
const forPostFn = () => { const forPostFn = () => {
addHighlightTool() const $article = document.getElementById('article-container')
addPhotoFigcaption() if (!$article) return
addJustifiedGallery(document.querySelectorAll('#article-container .gallery-container'))
runLightbox() addHighlightTool($article)
scrollFnToDo() addPhotoFigcaption($article)
addTableWrap() addJustifiedGallery($article.querySelectorAll('.gallery-container'))
clickFnOfTagHide() runLightbox($article)
tabsFn() scrollFnToDo($article)
addTableWrap($article)
clickFnOfTagHide($article)
tabsFn($article)
} }
const refreshFn = () => { const refreshFn = () => {
initAdjust() initAdjust()
goUpElement = document.getElementById('go-up')
scrollPercentElement = goUpElement?.querySelector('.scroll-percent')
justifiedIndexPostUI() justifiedIndexPostUI()
if (GLOBAL_CONFIG_SITE.pageType === 'post') { if (GLOBAL_CONFIG_SITE.pageType === 'post') {
+173 -264
View File
@@ -6,8 +6,28 @@ window.addEventListener('load', () => {
return console.error('Algolia setting is invalid!') return console.error('Algolia setting is invalid!')
} }
const CONTENT_FIELDS = ['contentStripTruncate', 'contentStrip', 'content']
const HIGHLIGHT_PARAMS = {
highlightPreTag: '<mark>',
highlightPostTag: '</mark>',
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 $searchMask = document.getElementById('search-mask')
const $searchDialog = document.querySelector('#algolia-search .search-dialog') 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 animateElements = show => {
const action = show ? 'animateIn' : 'animateOut' const action = show ? 'animateIn' : 'animateOut'
@@ -23,15 +43,12 @@ window.addEventListener('load', () => {
} }
} }
const openSearch = () => { // Debounced resize to avoid layout thrashing
btf.overflowPaddingR.add() let resizeTimer
animateElements(true) const onResize = () => {
showLoading(false) clearTimeout(resizeTimer)
resizeTimer = setTimeout(fixSafariHeight, 150)
setTimeout(() => { }
const searchInput = document.querySelector('#algolia-search-input .ais-SearchBox-input')
if (searchInput) searchInput.focus()
}, 100)
const handleEscape = event => { const handleEscape = event => {
if (event.code === 'Escape') { if (event.code === 'Escape') {
@@ -40,15 +57,29 @@ window.addEventListener('load', () => {
} }
} }
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) document.addEventListener('keydown', handleEscape)
fixSafariHeight() fixSafariHeight()
window.addEventListener('resize', fixSafariHeight) window.addEventListener('resize', onResize)
} }
const closeSearch = () => { const closeSearch = () => {
btf.overflowPaddingR.remove() btf.overflowPaddingR.remove()
animateElements(false) animateElements(false)
window.removeEventListener('resize', fixSafariHeight) document.removeEventListener('keydown', handleEscape)
window.removeEventListener('resize', onResize)
} }
const searchClickFn = () => { const searchClickFn = () => {
@@ -60,37 +91,40 @@ window.addEventListener('load', () => {
document.querySelector('#algolia-search .search-close-button').addEventListener('click', closeSearch) document.querySelector('#algolia-search .search-close-button').addEventListener('click', closeSearch)
} }
const cutContent = content => { const extractContentStr = content => {
if (!content) return '' if (!content) return ''
if (typeof content === 'string') return content.trim()
let contentStr = '' if (typeof content === 'object') {
if (typeof content === 'string') {
contentStr = content.trim()
} else if (typeof content === 'object') {
if (content.value !== undefined) { if (content.value !== undefined) {
contentStr = String(content.value).trim() const str = String(content.value).trim()
if (!contentStr) return '' return str || ''
} else if (content.matchedWords || content.matchLevel || content.fullyHighlighted !== undefined) { }
return '' if (content.matchedWords || content.matchLevel || content.fullyHighlighted !== undefined) return ''
} else {
try { try {
contentStr = JSON.stringify(content).trim() const str = JSON.stringify(content).trim()
if (contentStr === '{}' || contentStr === '[]' || contentStr === '""') { 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 '' return ''
} }
} catch (e) {
return '' const extractHighlightValue = highlightObj => {
} if (!highlightObj) return ''
} if (typeof highlightObj === 'string') return highlightObj.trim()
} else if (content.toString && typeof content.toString === 'function') { if (typeof highlightObj === 'object' && highlightObj.value !== undefined) {
contentStr = content.toString().trim() return String(highlightObj.value).trim()
if (contentStr === '[object Object]' || contentStr === '[object Array]') { }
return ''
}
} else {
return '' return ''
} }
const cutContent = content => {
const contentStr = extractContentStr(content)
if (!contentStr) return ''
const firstOccur = contentStr.indexOf('<mark>') const firstOccur = contentStr.indexOf('<mark>')
let start = firstOccur - 30 let start = firstOccur - 30
let end = firstOccur + 120 let end = firstOccur + 120
@@ -110,112 +144,67 @@ window.addEventListener('load', () => {
post = '...' post = '...'
} }
// Ensure we don't cut off HTML tags in the middle
let substr = contentStr.substring(start, end) let substr = contentStr.substring(start, end)
// Handle tag completeness // Remove incomplete tags at boundaries
// Check for incomplete opening tags at the beginning
const firstCloseBracket = substr.indexOf('>') const firstCloseBracket = substr.indexOf('>')
const firstOpenBracket = 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)) { if (firstCloseBracket !== -1 && (firstOpenBracket === -1 || firstCloseBracket < firstOpenBracket)) {
substr = substr.substring(firstCloseBracket + 1) substr = substr.substring(firstCloseBracket + 1)
} }
// Check for incomplete closing tags at the end
const lastOpenBracket = substr.lastIndexOf('<') const lastOpenBracket = substr.lastIndexOf('<')
const lastCloseBracket = 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) { if (lastOpenBracket !== -1 && lastOpenBracket > lastCloseBracket) {
substr = substr.substring(0, lastOpenBracket) substr = substr.substring(0, lastOpenBracket)
} }
// Balance tags in the substring // Balance tags using regex
const tagStack = [] const tagStack = []
let balancedStr = '' let balancedStr = ''
let i = 0 let lastIndex = 0
let match
while (i < substr.length) { TAG_REGEX.lastIndex = 0
if (substr[i] === '<') { while ((match = TAG_REGEX.exec(substr)) !== null) {
// Check if it's a closing tag const fullTag = match[0]
if (substr[i + 1] === '/') { const tagName = match[1]
const closeTagEnd = substr.indexOf('>', i) const tagStart = match.index
if (closeTagEnd !== -1) {
const closeTagName = substr.substring(i + 2, closeTagEnd) // Append text before this tag
// Remove matching opening tag from stack balancedStr += substr.substring(lastIndex, tagStart)
for (let j = tagStack.length - 1; j >= 0; j--) {
if (tagStack[j] === closeTagName) { if (fullTag.startsWith('</')) {
tagStack.splice(j, 1) // Closing tag - remove matching opening tag from stack
break const idx = tagStack.lastIndexOf(tagName)
} if (idx !== -1) tagStack.splice(idx, 1)
} } else if (!fullTag.endsWith('/>') && !fullTag.startsWith('<!')) {
balancedStr += substr.substring(i, closeTagEnd + 1) // Opening tag (not self-closing or comment)
i = closeTagEnd + 1
continue
}
} else if (substr.substr(i, 2) === '<!' || (substr.indexOf('/>', 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) tagStack.push(tagName)
balancedStr += substr.substring(i, tagEnd + 1)
i = tagEnd + 1
continue
} }
balancedStr += fullTag
lastIndex = tagStart + fullTag.length
} }
} balancedStr += substr.substring(lastIndex)
balancedStr += substr[i]
i++ // Close unclosed tags
for (let i = tagStack.length - 1; i >= 0; i--) {
balancedStr += `</${tagStack[i]}>`
} }
// Close any unclosed tags // Check if we cut a mark tag at the beginning
while (tagStack.length > 0) {
const tagName = tagStack.pop()
balancedStr += `</${tagName}>`
}
// If we removed content from the beginning, add prefix
if (start > 0 || pre) { if (start > 0 || pre) {
const actualFirstOpenBracket = contentStr.indexOf('<', start > 0 ? start - 30 : 0) const checkStart = Math.max(0, start - 30)
const actualFirstMark = contentStr.indexOf('<mark>', start > 0 ? start - 30 : 0) const actualFirstOpenBracket = contentStr.indexOf('<', checkStart)
const actualFirstMark = contentStr.indexOf('<mark>', checkStart)
if (actualFirstOpenBracket !== -1 && if (actualFirstOpenBracket !== -1 && (actualFirstMark === -1 || actualFirstOpenBracket < actualFirstMark)) {
(actualFirstMark === -1 || actualFirstOpenBracket < actualFirstMark)) {
pre = '...' pre = '...'
} }
} }
substr = balancedStr return `${pre}${balancedStr}${post}`
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 let searchClient
if (window['algoliasearch/lite'] && typeof window['algoliasearch/lite'].liteClient === 'function') { if (window['algoliasearch/lite'] && typeof window['algoliasearch/lite'].liteClient === 'function') {
@@ -230,96 +219,52 @@ window.addEventListener('load', () => {
return console.error('Failed to initialize Algolia search client') return console.error('Failed to initialize Algolia search client')
} }
// Search state
let currentQuery = '' let currentQuery = ''
let searchRequestId = 0 // Race condition guard
// 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 => { const toggleResultsVisibility = hasResults => {
elements.pagination.style.display = hasResults ? '' : 'none' $pagination.style.display = hasResults ? '' : 'none'
elements.stats.style.display = hasResults ? '' : 'none' $stats.style.display = hasResults ? '' : 'none'
} }
// Render search results
const renderHits = (hits, query, page = 0) => { const renderHits = (hits, query, page = 0) => {
if (hits.length === 0 && query) { if (hits.length === 0 && query) {
elements.hitsEmpty.textContent = languages.hits_empty.replace(/\$\{query}/, query) $hitsEmpty.textContent = languages.hits_empty.replace(/\$\{query}/, query)
elements.hitsEmpty.style.display = '' $hitsEmpty.style.display = ''
elements.hitsWrapper.style.display = 'none' $hitsWrapper.style.display = 'none'
elements.stats.style.display = 'none' $stats.style.display = 'none'
return return
} }
elements.hitsEmpty.style.display = 'none' $hitsEmpty.style.display = 'none'
const hitsHTML = hits.map((hit, index) => { const hitsHTML = hits.map((hit, index) => {
const itemNumber = page * hitsPerPage + index + 1 const itemNumber = page * hitsPerPage + index + 1
const link = hit.permalink || (GLOBAL_CONFIG.root + hit.path) const link = hit.permalink || (GLOBAL_CONFIG.root + hit.path)
const result = hit._highlightResult || hit const result = hit._highlightResult || hit
// Content extraction // Content extraction - try highlight result first, then raw hit
let content = '' let content = ''
try { for (const field of CONTENT_FIELDS) {
if (result.contentStripTruncate) { if (result[field]) { content = cutContent(result[field]); break }
content = cutContent(result.contentStripTruncate) if (hit[field]) { content = cutContent(hit[field]); break }
} 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 // Title handling - try highlight result first, then raw hit
let title = 'no-title' let title = 'no-title'
try { const titleSource = result.title || hit.title
if (result.title) { if (titleSource) {
title = extractHighlightValue(result.title) || 'no-title' title = extractHighlightValue(titleSource) || 'no-title'
} else if (hit.title) {
title = extractHighlightValue(hit.title) || 'no-title'
} }
if (title === 'no-title') {
if (!title || title === 'no-title') {
if (typeof hit.title === 'string' && hit.title.trim()) { if (typeof hit.title === 'string' && hit.title.trim()) {
title = hit.title.trim() title = hit.title.trim()
} else if (hit.title && typeof hit.title === 'object' && hit.title.value) { } else if (hit.title?.value) {
title = String(hit.title.value).trim() || 'no-title' title = String(hit.title.value).trim() || 'no-title'
} else {
title = 'no-title'
} }
} }
} catch (error) {
title = 'no-title'
}
return ` return `<li class="ais-Hits-item" value="${itemNumber}">
<li class="ais-Hits-item" value="${itemNumber}">
<a href="${link}" class="algolia-hit-item-link"> <a href="${link}" class="algolia-hit-item-link">
<span class="algolia-hits-item-title">${title}</span> <span class="algolia-hits-item-title">${title}</span>
${content ? `<div class="algolia-hit-item-content">${content}</div>` : ''} ${content ? `<div class="algolia-hit-item-content">${content}</div>` : ''}
@@ -327,24 +272,21 @@ window.addEventListener('load', () => {
</li>` </li>`
}).join('') }).join('')
elements.hitsList.innerHTML = hitsHTML $hitsList.innerHTML = hitsHTML
elements.hitsWrapper.style.display = query ? '' : 'none' $hitsWrapper.style.display = query ? '' : 'none'
if (hits.length > 0) { if (hits.length > 0) {
elements.stats.style.display = '' $stats.style.display = ''
} }
} }
// Render pagination
const renderPagination = (page, nbPages) => { const renderPagination = (page, nbPages) => {
if (nbPages <= 1) { if (nbPages <= 1) {
elements.pagination.style.display = 'none' $pagination.style.display = 'none'
elements.paginationList.innerHTML = '' $paginationList.innerHTML = ''
return return
} }
elements.pagination.style.display = 'block'
const isFirstPage = page === 0 const isFirstPage = page === 0
const isLastPage = page === nbPages - 1 const isLastPage = page === nbPages - 1
@@ -359,90 +301,63 @@ window.addEventListener('load', () => {
startPage = Math.max(0, endPage - maxVisiblePages + 1) startPage = Math.max(0, endPage - maxVisiblePages + 1)
} }
let pagesHTML = '' const parts = []
// Only add ellipsis and first page when there are many pages // Only add ellipsis and first page when there are many pages
if (nbPages > maxVisiblePages && startPage > 0) { if (nbPages > maxVisiblePages && startPage > 0) {
pagesHTML += ` parts.push('<li class="ais-Pagination-item ais-Pagination-item--page"><a class="ais-Pagination-link" aria-label="Page 1" href="#" data-page="0">1</a></li>')
<li class="ais-Pagination-item ais-Pagination-item--page">
<a class="ais-Pagination-link" aria-label="Page 1" href="#" data-page="0">1</a>
</li>`
if (startPage > 1) { if (startPage > 1) {
pagesHTML += ` parts.push('<li class="ais-Pagination-item ais-Pagination-item--ellipsis"><span class="ais-Pagination-link">...</span></li>')
<li class="ais-Pagination-item ais-Pagination-item--ellipsis">
<span class="ais-Pagination-link">...</span>
</li>`
} }
} }
// Add middle page numbers // Add middle page numbers
for (let i = startPage; i <= endPage; i++) { for (let i = startPage; i <= endPage; i++) {
const isSelected = i === page if (i === page) {
if (isSelected) { parts.push(`<li class="ais-Pagination-item ais-Pagination-item--page ais-Pagination-item--selected"><span class="ais-Pagination-link" aria-label="Page ${i + 1}">${i + 1}</span></li>`)
pagesHTML += `
<li class="ais-Pagination-item ais-Pagination-item--page ais-Pagination-item--selected">
<span class="ais-Pagination-link" aria-label="Page ${i + 1}">${i + 1}</span>
</li>`
} else { } else {
pagesHTML += ` parts.push(`<li class="ais-Pagination-item ais-Pagination-item--page"><a class="ais-Pagination-link" aria-label="Page ${i + 1}" href="#" data-page="${i}">${i + 1}</a></li>`)
<li class="ais-Pagination-item ais-Pagination-item--page">
<a class="ais-Pagination-link" aria-label="Page ${i + 1}" href="#" data-page="${i}">${i + 1}</a>
</li>`
} }
} }
// Only add ellipsis and last page when there are many pages // Only add ellipsis and last page when there are many pages
if (nbPages > maxVisiblePages && endPage < nbPages - 1) { if (nbPages > maxVisiblePages && endPage < nbPages - 1) {
if (endPage < nbPages - 2) { if (endPage < nbPages - 2) {
pagesHTML += ` parts.push('<li class="ais-Pagination-item ais-Pagination-item--ellipsis"><span class="ais-Pagination-link">...</span></li>')
<li class="ais-Pagination-item ais-Pagination-item--ellipsis">
<span class="ais-Pagination-link">...</span>
</li>`
} }
pagesHTML += ` parts.push(`<li class="ais-Pagination-item ais-Pagination-item--page"><a class="ais-Pagination-link" aria-label="Page ${nbPages}" href="#" data-page="${nbPages - 1}">${nbPages}</a></li>`)
<li class="ais-Pagination-item ais-Pagination-item--page">
<a class="ais-Pagination-link" aria-label="Page ${nbPages}" href="#" data-page="${nbPages - 1}">${nbPages}</a>
</li>`
} }
if (nbPages > 1) { // Build prev/next links
elements.paginationList.innerHTML = ` const prevLink = isFirstPage
<li class="ais-Pagination-item ais-Pagination-item--previousPage ${isFirstPage ? 'ais-Pagination-item--disabled' : ''}">
${isFirstPage
? '<span class="ais-Pagination-link ais-Pagination-link--disabled" aria-label="Previous Page"><i class="fas fa-angle-left"></i></span>' ? '<span class="ais-Pagination-link ais-Pagination-link--disabled" aria-label="Previous Page"><i class="fas fa-angle-left"></i></span>'
: `<a class="ais-Pagination-link" aria-label="Previous Page" href="#" data-page="${page - 1}"><i class="fas fa-angle-left"></i></a>` : `<a class="ais-Pagination-link" aria-label="Previous Page" href="#" data-page="${page - 1}"><i class="fas fa-angle-left"></i></a>`
} const nextLink = isLastPage
</li>
${pagesHTML}
<li class="ais-Pagination-item ais-Pagination-item--nextPage ${isLastPage ? 'ais-Pagination-item--disabled' : ''}">
${isLastPage
? '<span class="ais-Pagination-link ais-Pagination-link--disabled" aria-label="Next Page"><i class="fas fa-angle-right"></i></span>' ? '<span class="ais-Pagination-link ais-Pagination-link--disabled" aria-label="Next Page"><i class="fas fa-angle-right"></i></span>'
: `<a class="ais-Pagination-link" aria-label="Next Page" href="#" data-page="${page + 1}"><i class="fas fa-angle-right"></i></a>` : `<a class="ais-Pagination-link" aria-label="Next Page" href="#" data-page="${page + 1}"><i class="fas fa-angle-right"></i></a>`
}
</li>` $paginationList.innerHTML = `<li class="ais-Pagination-item ais-Pagination-item--previousPage ${isFirstPage ? 'ais-Pagination-item--disabled' : ''}">${prevLink}</li>${parts.join('')}<li class="ais-Pagination-item ais-Pagination-item--nextPage ${isLastPage ? 'ais-Pagination-item--disabled' : ''}">${nextLink}</li>`
elements.pagination.style.display = currentQuery ? '' : 'none' $pagination.style.display = currentQuery ? '' : 'none'
} else {
elements.pagination.style.display = 'none'
}
} }
// Render statistics
const renderStats = (nbHits, processingTimeMS, query) => { const renderStats = (nbHits, processingTimeMS, query) => {
if (query) { if (query) {
const stats = languages.hits_stats const stats = languages.hits_stats
.replace(/\$\{hits}/, nbHits) .replace(/\$\{hits}/, nbHits)
.replace(/\$\{time}/, processingTimeMS) .replace(/\$\{time}/, processingTimeMS)
elements.stats.innerHTML = `<hr>${stats}` $stats.innerHTML = `<hr>${stats}`
elements.stats.style.display = '' $stats.style.display = ''
} else { } else {
elements.stats.style.display = 'none' $stats.style.display = 'none'
} }
} }
// Perform search
const performSearch = async (query, page = 0) => { const performSearch = async (query, page = 0) => {
if (!query.trim()) { const trimmedQuery = query.trim()
if (!trimmedQuery) {
currentQuery = '' currentQuery = ''
searchRequestId++
renderHits([], '', 0) renderHits([], '', 0)
renderPagination(0, 0) renderPagination(0, 0)
renderStats(0, 0, '') renderStats(0, 0, '')
@@ -451,7 +366,8 @@ window.addEventListener('load', () => {
} }
showLoading(true) showLoading(true)
currentQuery = query currentQuery = trimmedQuery
const requestId = ++searchRequestId
try { try {
let result let result
@@ -460,82 +376,76 @@ window.addEventListener('load', () => {
// v5 multi-index search // v5 multi-index search
const searchResult = await searchClient.search([{ const searchResult = await searchClient.search([{
indexName, indexName,
query, query: trimmedQuery,
params: { params: { page, hitsPerPage, ...HIGHLIGHT_PARAMS }
page,
hitsPerPage,
highlightPreTag: '<mark>',
highlightPostTag: '</mark>',
attributesToHighlight: ['title', 'content', 'contentStrip', 'contentStripTruncate']
}
}]) }])
result = searchResult.results[0] result = searchResult.results[0]
} else if (searchClient && typeof searchClient.initIndex === 'function') { } else if (searchClient && typeof searchClient.initIndex === 'function') {
// v4 single-index search // v4 single-index search
const index = searchClient.initIndex(indexName) const index = searchClient.initIndex(indexName)
result = await index.search(query, { result = await index.search(trimmedQuery, { page, hitsPerPage, ...HIGHLIGHT_PARAMS })
page,
hitsPerPage,
highlightPreTag: '<mark>',
highlightPostTag: '</mark>',
attributesToHighlight: ['title', 'content', 'contentStrip', 'contentStripTruncate']
})
} else { } else {
throw new Error('Algolia: No compatible search method available') throw new Error('Algolia: No compatible search method available')
} }
renderHits(result.hits || [], query, page) // Discard stale results from superseded searches
if (requestId !== searchRequestId) return
renderHits(result.hits || [], trimmedQuery, page)
const actualNbPages = result.nbHits <= hitsPerPage ? 1 : (result.nbPages || 0) const actualNbPages = result.nbHits <= hitsPerPage ? 1 : (result.nbPages || 0)
renderPagination(page, actualNbPages) renderPagination(page, actualNbPages)
renderStats(result.nbHits || 0, result.processingTimeMS || 0, query) renderStats(result.nbHits || 0, result.processingTimeMS || 0, trimmedQuery)
const hasResults = result.hits && result.hits.length > 0 const hasResults = result.hits && result.hits.length > 0
toggleResultsVisibility(hasResults) toggleResultsVisibility(hasResults)
// Refresh Pjax links // Refresh Pjax links
if (window.pjax) { if (window.pjax) {
window.pjax.refresh(document.getElementById('algolia-hits')) window.pjax.refresh($hits)
} }
} catch (error) { } catch (error) {
if (requestId !== searchRequestId) return
console.error('Algolia search error:', error) console.error('Algolia search error:', error)
renderHits([], query, page) renderHits([], trimmedQuery, page)
renderPagination(0, 0) renderPagination(0, 0)
renderStats(0, 0, query) renderStats(0, 0, trimmedQuery)
} finally { } finally {
if (requestId === searchRequestId) {
showLoading(false) showLoading(false)
} }
} }
}
// Debounced search
let searchTimeout let searchTimeout
const debouncedSearch = (query, delay = 300) => { const debouncedSearch = (query, delay = 300) => {
clearTimeout(searchTimeout) clearTimeout(searchTimeout)
// Empty query: clear results immediately without debounce delay
if (!query.trim()) {
performSearch(query)
return
}
searchTimeout = setTimeout(() => performSearch(query), delay) searchTimeout = setTimeout(() => performSearch(query), delay)
} }
// Initialize search box and events
const initializeSearch = () => { const initializeSearch = () => {
showLoading(false) showLoading(false)
if (elements.searchInput) { if ($searchInput) {
elements.searchInput.addEventListener('input', e => { $searchInput.addEventListener('input', e => {
const query = e.target.value debouncedSearch(e.target.value)
debouncedSearch(query)
}) })
} }
const searchForm = document.querySelector('#algolia-search-input .ais-SearchBox-form') if ($searchForm) {
if (searchForm) { $searchForm.addEventListener('submit', e => {
searchForm.addEventListener('submit', e => {
e.preventDefault() e.preventDefault()
const query = elements.searchInput.value performSearch($searchInput ? $searchInput.value : '')
performSearch(query)
}) })
} }
// Pagination event delegation // Pagination event delegation
elements.pagination.addEventListener('click', e => { $pagination.addEventListener('click', e => {
e.preventDefault() e.preventDefault()
const link = e.target.closest('a[data-page]') const link = e.target.closest('a[data-page]')
if (link) { if (link) {
@@ -550,7 +460,6 @@ window.addEventListener('load', () => {
toggleResultsVisibility(false) toggleResultsVisibility(false)
} }
// Initialize
initializeSearch() initializeSearch()
searchClickFn() searchClickFn()
searchFnOnce() searchFnOnce()
+107 -107
View File
@@ -15,31 +15,39 @@ class LocalSearch {
this.top_n_per_article = top_n_per_article this.top_n_per_article = top_n_per_article
this.isfetched = false this.isfetched = false
this.datas = null 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) { getIndexByWord (words, text, caseSensitive = false) {
const index = [] const index = []
const included = new Set() const included = new Set()
const processedWords = this._processKeywords(words)
if (!caseSensitive) { if (!caseSensitive) {
text = text.toLowerCase() text = text.toLowerCase()
} }
words.forEach(word => { processedWords.forEach((word, i) => {
if (this.unescape) {
const div = document.createElement('div')
div.innerText = word
word = div.innerHTML
}
const wordLen = word.length const wordLen = word.length
if (wordLen === 0) return if (wordLen === 0) return
let startPosition = 0 let startPosition = 0
let position = -1 let position = -1
if (!caseSensitive) { const searchWord = caseSensitive ? word : word.toLowerCase()
word = word.toLowerCase() while ((position = text.indexOf(searchWord, startPosition)) > -1) {
}
while ((position = text.indexOf(word, startPosition)) > -1) {
index.push({ position, word }) index.push({ position, word })
included.add(word) included.add(words[i])
startPosition = position + wordLen startPosition = position + wordLen
} }
}) })
@@ -90,19 +98,22 @@ class LocalSearch {
// Highlight title and content // Highlight title and content
highlightKeyword (val, slice) { highlightKeyword (val, slice) {
let result = '' const parts = []
let index = slice.start let index = slice.start
for (const { position, length } of slice.hits) { for (const { position, length } of slice.hits) {
result += val.substring(index, position) parts.push(val.substring(index, position))
index = position + length index = position + length
result += `<mark class="search-keyword">${val.substr(position, length)}</mark>` parts.push(`<mark class="search-keyword">${val.substring(position, position + length)}</mark>`)
} }
result += val.substring(index, slice.end) parts.push(val.substring(index, slice.end))
return result return parts.join('')
} }
getResultItems (keywords) { getResultItems (keywords) {
const resultItems = [] const resultItems = []
this._processedKeywords = null
// Compute highlight param once instead of per-article
const highlightParam = keywords.join(' ')
this.datas.forEach(({ title, content, url }) => { this.datas.forEach(({ title, content, url }) => {
// The number of different keywords included in the article. // The number of different keywords included in the article.
const [indexOfTitle, keysOfTitle] = this.getIndexByWord(keywords, title) const [indexOfTitle, keysOfTitle] = this.getIndexByWord(keywords, title)
@@ -147,7 +158,7 @@ class LocalSearch {
let resultItem = '' let resultItem = ''
url = new URL(url, location.origin) url = new URL(url, location.origin)
url.searchParams.append('highlight', keywords.join(' ')) url.searchParams.append('highlight', highlightParam)
if (slicesOfTitle.length !== 0) { if (slicesOfTitle.length !== 0) {
resultItem += `<li class="local-search-hit-item"><a href="${url.href}"><span class="search-result-title">${this.highlightKeyword(title, slicesOfTitle[0])}</span>` resultItem += `<li class="local-search-hit-item"><a href="${url.href}"><span class="search-result-title">${this.highlightKeyword(title, slicesOfTitle[0])}</span>`
@@ -173,7 +184,10 @@ class LocalSearch {
fetchData () { fetchData () {
const isXml = !this.path.endsWith('json') const isXml = !this.path.endsWith('json')
fetch(this.path) fetch(this.path)
.then(response => response.text()) .then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`)
return response.text()
})
.then(res => { .then(res => {
// Get the contents from search data // Get the contents from search data
this.isfetched = true this.isfetched = true
@@ -194,6 +208,12 @@ class LocalSearch {
// Remove loading animation // Remove loading animation
window.dispatchEvent(new Event('search:loaded')) 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 // Highlight by wrapping node in mark elements with the given class name
@@ -206,7 +226,7 @@ class LocalSearch {
index = position + length index = position + length
const mark = document.createElement('mark') const mark = document.createElement('mark')
mark.className = className mark.className = className
mark.appendChild(document.createTextNode(val.substr(position, length))) mark.appendChild(document.createTextNode(val.substring(position, position + length)))
children.push(text, mark) children.push(text, mark)
} }
node.nodeValue = val.substring(index, slice.end) node.nodeValue = val.substring(index, slice.end)
@@ -244,9 +264,14 @@ window.addEventListener('load', () => {
unescape unescape
}) })
const input = document.querySelector('.local-search-input input') const $input = document.querySelector('.local-search-input input')
const statsItem = document.getElementById('local-search-stats') const $statsItem = document.getElementById('local-search-stats')
const $loadingStatus = document.getElementById('loading-status') 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') const isXml = !path.endsWith('json')
// Pagination variables (only initialize if pagination is enabled) // Pagination variables (only initialize if pagination is enabled)
@@ -256,30 +281,17 @@ window.addEventListener('load', () => {
let currentResultItems = [] let currentResultItems = []
if (!enablePagination) { if (!enablePagination) {
// If pagination is disabled, we don't need these variables
currentPage = undefined currentPage = undefined
currentResultItems = 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 // Show/hide search results area
const toggleResultsVisibility = hasResults => { const toggleResultsVisibility = hasResults => {
if (enablePagination) { $pagination.style.display = (hasResults && enablePagination) ? '' : 'none'
elements.pagination.style.display = hasResults ? '' : 'none'
} else {
elements.pagination.style.display = 'none'
}
} }
// Render search results for current page // Render search results for current page
const renderResults = (searchText, resultItems) => { const renderResults = (searchText, resultItems) => {
const container = document.getElementById('local-search-results')
// Determine items to display based on pagination mode // Determine items to display based on pagination mode
const itemsToDisplay = enablePagination const itemsToDisplay = enablePagination
? currentResultItems.slice(currentPage * hitsPerPage, (currentPage + 1) * hitsPerPage) ? currentResultItems.slice(currentPage * hitsPerPage, (currentPage + 1) * hitsPerPage)
@@ -303,12 +315,12 @@ window.addEventListener('load', () => {
) )
}) })
container.innerHTML = `<ol class="search-result-list">${numberedItems.join('')}</ol>` $results.innerHTML = `<ol class="search-result-list">${numberedItems.join('')}</ol>`
// Update stats // Update stats
const displayCount = enablePagination ? currentResultItems.length : resultItems.length const displayCount = enablePagination ? currentResultItems.length : resultItems.length
const stats = languages.hits_stats.replace(/\$\{hits}/, displayCount) const stats = languages.hits_stats.replace(/\$\{hits}/, displayCount)
statsItem.innerHTML = `<hr><div class="search-result-stats">${stats}</div>` $statsItem.innerHTML = `<hr><div class="search-result-stats">${stats}</div>`
// Handle pagination // Handle pagination
if (enablePagination) { if (enablePagination) {
@@ -319,19 +331,17 @@ window.addEventListener('load', () => {
const hasResults = resultItems.length > 0 const hasResults = resultItems.length > 0
toggleResultsVisibility(hasResults) toggleResultsVisibility(hasResults)
window.pjax && window.pjax.refresh(container) window.pjax && window.pjax.refresh($results)
} }
// Render pagination // Render pagination
const renderPagination = (page, nbPages, query) => { const renderPagination = (page, nbPages) => {
if (nbPages <= 1) { if (nbPages <= 1) {
elements.pagination.style.display = 'none' $pagination.style.display = 'none'
elements.paginationList.innerHTML = '' $paginationList.innerHTML = ''
return return
} }
elements.pagination.style.display = 'block'
const isFirstPage = page === 0 const isFirstPage = page === 0
const isLastPage = page === nbPages - 1 const isLastPage = page === nbPages - 1
@@ -346,77 +356,49 @@ window.addEventListener('load', () => {
startPage = Math.max(0, endPage - maxVisiblePages + 1) startPage = Math.max(0, endPage - maxVisiblePages + 1)
} }
let pagesHTML = '' const parts = []
// Only add ellipsis and first page when there are many pages // Only add ellipsis and first page when there are many pages
if (nbPages > maxVisiblePages && startPage > 0) { if (nbPages > maxVisiblePages && startPage > 0) {
pagesHTML += ` parts.push('<li class="ais-Pagination-item ais-Pagination-item--page"><a class="ais-Pagination-link" aria-label="Page 1" href="#" data-page="0">1</a></li>')
<li class="ais-Pagination-item ais-Pagination-item--page">
<a class="ais-Pagination-link" aria-label="Page 1" href="#" data-page="0">1</a>
</li>`
if (startPage > 1) { if (startPage > 1) {
pagesHTML += ` parts.push('<li class="ais-Pagination-item ais-Pagination-item--ellipsis"><span class="ais-Pagination-link">...</span></li>')
<li class="ais-Pagination-item ais-Pagination-item--ellipsis">
<span class="ais-Pagination-link">...</span>
</li>`
} }
} }
// Add middle page numbers // Add middle page numbers
for (let i = startPage; i <= endPage; i++) { for (let i = startPage; i <= endPage; i++) {
const isSelected = i === page if (i === page) {
if (isSelected) { parts.push(`<li class="ais-Pagination-item ais-Pagination-item--page ais-Pagination-item--selected"><span class="ais-Pagination-link" aria-label="Page ${i + 1}">${i + 1}</span></li>`)
pagesHTML += `
<li class="ais-Pagination-item ais-Pagination-item--page ais-Pagination-item--selected">
<span class="ais-Pagination-link" aria-label="Page ${i + 1}">${i + 1}</span>
</li>`
} else { } else {
pagesHTML += ` parts.push(`<li class="ais-Pagination-item ais-Pagination-item--page"><a class="ais-Pagination-link" aria-label="Page ${i + 1}" href="#" data-page="${i}">${i + 1}</a></li>`)
<li class="ais-Pagination-item ais-Pagination-item--page">
<a class="ais-Pagination-link" aria-label="Page ${i + 1}" href="#" data-page="${i}">${i + 1}</a>
</li>`
} }
} }
// Only add ellipsis and last page when there are many pages // Only add ellipsis and last page when there are many pages
if (nbPages > maxVisiblePages && endPage < nbPages - 1) { if (nbPages > maxVisiblePages && endPage < nbPages - 1) {
if (endPage < nbPages - 2) { if (endPage < nbPages - 2) {
pagesHTML += ` parts.push('<li class="ais-Pagination-item ais-Pagination-item--ellipsis"><span class="ais-Pagination-link">...</span></li>')
<li class="ais-Pagination-item ais-Pagination-item--ellipsis">
<span class="ais-Pagination-link">...</span>
</li>`
} }
pagesHTML += ` parts.push(`<li class="ais-Pagination-item ais-Pagination-item--page"><a class="ais-Pagination-link" aria-label="Page ${nbPages}" href="#" data-page="${nbPages - 1}">${nbPages}</a></li>`)
<li class="ais-Pagination-item ais-Pagination-item--page">
<a class="ais-Pagination-link" aria-label="Page ${nbPages}" href="#" data-page="${nbPages - 1}">${nbPages}</a>
</li>`
} }
if (nbPages > 1) { // Build prev/next links
elements.paginationList.innerHTML = ` const prevLink = isFirstPage
<li class="ais-Pagination-item ais-Pagination-item--previousPage ${isFirstPage ? 'ais-Pagination-item--disabled' : ''}">
${isFirstPage
? '<span class="ais-Pagination-link ais-Pagination-link--disabled" aria-label="Previous Page"><i class="fas fa-angle-left"></i></span>' ? '<span class="ais-Pagination-link ais-Pagination-link--disabled" aria-label="Previous Page"><i class="fas fa-angle-left"></i></span>'
: `<a class="ais-Pagination-link" aria-label="Previous Page" href="#" data-page="${page - 1}"><i class="fas fa-angle-left"></i></a>` : `<a class="ais-Pagination-link" aria-label="Previous Page" href="#" data-page="${page - 1}"><i class="fas fa-angle-left"></i></a>`
} const nextLink = isLastPage
</li>
${pagesHTML}
<li class="ais-Pagination-item ais-Pagination-item--nextPage ${isLastPage ? 'ais-Pagination-item--disabled' : ''}">
${isLastPage
? '<span class="ais-Pagination-link ais-Pagination-link--disabled" aria-label="Next Page"><i class="fas fa-angle-right"></i></span>' ? '<span class="ais-Pagination-link ais-Pagination-link--disabled" aria-label="Next Page"><i class="fas fa-angle-right"></i></span>'
: `<a class="ais-Pagination-link" aria-label="Next Page" href="#" data-page="${page + 1}"><i class="fas fa-angle-right"></i></a>` : `<a class="ais-Pagination-link" aria-label="Next Page" href="#" data-page="${page + 1}"><i class="fas fa-angle-right"></i></a>`
}
</li>` $paginationList.innerHTML = `<li class="ais-Pagination-item ais-Pagination-item--previousPage ${isFirstPage ? 'ais-Pagination-item--disabled' : ''}">${prevLink}</li>${parts.join('')}<li class="ais-Pagination-item ais-Pagination-item--nextPage ${isLastPage ? 'ais-Pagination-item--disabled' : ''}">${nextLink}</li>`
} else { $pagination.style.display = ''
elements.pagination.style.display = 'none'
}
} }
// Clear search results and stats // Clear search results and stats
const clearSearchResults = () => { const clearSearchResults = () => {
const container = document.getElementById('local-search-results') $results.textContent = ''
container.textContent = '' $statsItem.textContent = ''
statsItem.textContent = ''
toggleResultsVisibility(false) toggleResultsVisibility(false)
if (enablePagination) { if (enablePagination) {
currentResultItems = [] currentResultItems = []
@@ -426,12 +408,11 @@ window.addEventListener('load', () => {
// Show no results message // Show no results message
const showNoResults = searchText => { const showNoResults = searchText => {
const container = document.getElementById('local-search-results') $results.textContent = ''
container.textContent = ''
const statsDiv = document.createElement('div') const statsDiv = document.createElement('div')
statsDiv.className = 'search-result-stats' statsDiv.className = 'search-result-stats'
statsDiv.textContent = languages.hits_empty.replace(/\$\{query}/, searchText) statsDiv.textContent = languages.hits_empty.replace(/\$\{query}/, searchText)
statsItem.innerHTML = statsDiv.outerHTML $statsItem.innerHTML = statsDiv.outerHTML
toggleResultsVisibility(false) toggleResultsVisibility(false)
if (enablePagination) { if (enablePagination) {
currentResultItems = [] currentResultItems = []
@@ -441,7 +422,7 @@ window.addEventListener('load', () => {
const inputEventFunction = () => { const inputEventFunction = () => {
if (!localSearch.isfetched) return if (!localSearch.isfetched) return
let searchText = input.value.trim().toLowerCase() let searchText = $input.value.trim().toLowerCase()
isXml && (searchText = searchText.replace(/</g, '&lt;').replace(/>/g, '&gt;')) isXml && (searchText = searchText.replace(/</g, '&lt;').replace(/>/g, '&gt;'))
if (searchText !== '') $loadingStatus.hidden = false if (searchText !== '') $loadingStatus.hidden = false
@@ -478,44 +459,63 @@ window.addEventListener('load', () => {
$loadingStatus.hidden = true $loadingStatus.hidden = true
} }
let loadFlag = false // Debounced input handler
const $searchMask = document.getElementById('search-mask') let searchTimeout
const $searchDialog = document.querySelector('#local-search .search-dialog') 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
// fix safari
const fixSafariHeight = () => { const fixSafariHeight = () => {
if (window.innerWidth < 768) { if (window.innerWidth < 768) {
$searchDialog.style.setProperty('--search-height', window.innerHeight + 'px') $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 = () => { const openSearch = () => {
btf.overflowPaddingR.add() btf.overflowPaddingR.add()
btf.animateIn($searchMask, 'to_show 0.5s') btf.animateIn($searchMask, 'to_show 0.5s')
btf.animateIn($searchDialog, 'titleScale 0.5s') btf.animateIn($searchDialog, 'titleScale 0.5s')
setTimeout(() => { input.focus() }, 300) setTimeout(() => { $input.focus() }, 300)
if (!loadFlag) { if (!loadFlag) {
!localSearch.isfetched && localSearch.fetchData() !localSearch.isfetched && localSearch.fetchData()
input.addEventListener('input', inputEventFunction) $input.addEventListener('input', debouncedInputEvent)
loadFlag = true loadFlag = true
} }
// shortcut: ESC // shortcut: ESC
document.addEventListener('keydown', function f (event) { document.addEventListener('keydown', handleEscape)
if (event.code === 'Escape') {
closeSearch()
document.removeEventListener('keydown', f)
}
})
fixSafariHeight() fixSafariHeight()
window.addEventListener('resize', fixSafariHeight) window.addEventListener('resize', onResize)
} }
const closeSearch = () => { const closeSearch = () => {
btf.overflowPaddingR.remove() btf.overflowPaddingR.remove()
btf.animateOut($searchDialog, 'search_close .5s') btf.animateOut($searchDialog, 'search_close .5s')
btf.animateOut($searchMask, 'to_hide 0.5s') btf.animateOut($searchMask, 'to_hide 0.5s')
window.removeEventListener('resize', fixSafariHeight) document.removeEventListener('keydown', handleEscape)
window.removeEventListener('resize', onResize)
} }
const searchClickFn = () => { const searchClickFn = () => {
@@ -532,14 +532,14 @@ window.addEventListener('load', () => {
// Pagination event delegation - only add if pagination is enabled // Pagination event delegation - only add if pagination is enabled
if (enablePagination) { if (enablePagination) {
elements.pagination.addEventListener('click', e => { $pagination.addEventListener('click', e => {
e.preventDefault() e.preventDefault()
const link = e.target.closest('a[data-page]') const link = e.target.closest('a[data-page]')
if (link) { if (link) {
const page = parseInt(link.dataset.page, 10) const page = parseInt(link.dataset.page, 10)
if (!isNaN(page) && currentResultItems.length > 0) { if (!isNaN(page) && currentResultItems.length > 0) {
currentPage = page currentPage = page
renderResults(input.value.trim().toLowerCase(), currentResultItems) renderResults($input.value.trim().toLowerCase(), currentResultItems)
} }
} }
}) })
+24 -11
View File
File diff suppressed because one or more lines are too long
+36 -9
View File
@@ -45,28 +45,54 @@
} }
}, },
overflowPaddingR: { 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: () => { add: () => {
const paddingRight = window.innerWidth - document.body.clientWidth const paddingRight = window.innerWidth - document.body.clientWidth
if (paddingRight > 0) { if (paddingRight > 0) {
document.body.style.paddingRight = `${paddingRight}px` document.body.style.paddingRight = `${paddingRight}px`
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
const menuElement = document.querySelector('#page-header.nav-fixed #menus') const { headerElement: header, menuElement: menu } = getElements()
if (menuElement) { if (header && menu && header.classList.contains('nav-fixed')) {
menuElement.style.paddingRight = `${paddingRight}px` menu.style.paddingRight = `${paddingRight}px`
} }
} }
}, },
remove: () => { remove: () => {
document.body.style.paddingRight = '' document.body.style.paddingRight = ''
document.body.style.overflow = '' document.body.style.overflow = ''
const menuElement = document.querySelector('#page-header.nav-fixed #menus') const { headerElement: header, menuElement: menu } = getElements()
if (menuElement) { if (header && menu && header.classList.contains('nav-fixed')) {
menuElement.style.paddingRight = '' menu.style.paddingRight = ''
} }
} }
}, }
})(),
snackbarShow: (text, showAction = false, duration = 2000) => { snackbarShow: (text, showAction = false, duration = 2000) => {
const { position, bgLight, bgDark } = GLOBAL_CONFIG.Snackbar const { position, bgLight, bgDark } = GLOBAL_CONFIG.Snackbar
@@ -133,7 +159,8 @@
const animate = currentTime => { const animate = currentTime => {
const timeElapsed = currentTime - startTime const timeElapsed = currentTime - startTime
const progress = Math.min(timeElapsed / time, 1) 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) { if (progress < 1) {
requestAnimationFrame(animate) requestAnimationFrame(animate)
} }