🔍 搜索功能改進:

- 新增本地搜索分頁配置 (enablePagination, hitsPerPage)
- 重構 Algolia 搜索邏輯,支援多索引和更好的錯誤處理
- 優化搜索 UI 樣式,包括分頁按鈕和響應式設計
- 改進搜索結果顯示,新增編號和更好的高亮處理

📦 依賴項更新:
- 更新 plugins.yml 中的多個插件版本 (abcjs, algolia, aplayer 等)
- 更新 package.json 版本號為 5.5.0

🎨 UI/UX 優化:
- 改進側邊欄和目錄的動畫效果
- 優化樣式佈局,調整寬度百分比
- 新增說說頁面的分頁導航組件
- 改進右側邊欄按鈕樣式

🐛 錯誤處理和代碼優化:
- 修復 Umami Analytics 的錯誤處理和數據驗證
- 改進懶加載圖片的正則表達式,避免匹配腳本標籤
- 移除未使用的變數和改進代碼結構
- 新增說說內容的 Markdown 渲染支援

🔧 其他改進:
- 更新翻譯功能,移除箭頭函數語法以提升相容性
This commit is contained in:
Jerry
2025-09-09 15:40:08 +08:00
Unverified
parent ec1a226774
commit 67c95cee0c
33 changed files with 1479 additions and 450 deletions
+11 -2
View File
@@ -181,6 +181,11 @@
.card-category-list
&.child
padding: 0 0 0 16px
overflow: hidden
max-height: 0
opacity: 0
visibility: hidden
transition: max-height .3s ease, opacity .3s ease
> .parent
> a
@@ -189,7 +194,9 @@
transform: rotate(-90deg)
& + .child
display: block
max-height: 1000px
opacity: 1
visibility: visible
.card-category-list
&-name
@@ -208,7 +215,9 @@
if hexo-config('aside.card_categories.expand') == false
> .child
display: none
max-height: 0
opacity: 0
visibility: hidden
.card-webinfo
.webinfo
+4
View File
@@ -38,9 +38,13 @@
font-size: 16px
line-height: w
addBorderRadius(5)
@extend .btn-effects
&:hover
background-color: var(--btn-hover-color)
i
vertical-align: baseline
#mobile-toc-button
display: none
+21 -2
View File
@@ -52,7 +52,10 @@
&:hover
background: var(--text-bg-hover)
box-shadow: 0 2px 8px rgba(0, 0, 0, .1)
color: var(--white)
transition: all .2s ease
transform: translateX(3px)
i:first-child
width: 15%
@@ -70,9 +73,25 @@
transform: rotate(90deg)
& + .menus_item_child
display: none
overflow: hidden
max-height: 0
opacity: 0
transform: scaleY(0)
transform-origin: top
.menus_item_child
margin: 0
padding-left: 25px
list-style: none
max-height: 0
list-style: none
opacity: 0
transition: transform .3s ease, opacity .3s ease, max-height .3s ease
transform: scaleY(0)
transform-origin: top
will-change: transform, opacity, max-height
// .hide
.site-page.group:not(.hide) + .menus_item_child
max-height: 1000px
opacity: 1
transform: scaleY(1)
+1 -1
View File
@@ -18,7 +18,7 @@
padding: 20px 5px
+minWidth2000()
max-width: 70%
max-width: 60%
& > div:first-child:not(.nc)
@extend .cardHover
+107
View File
@@ -76,3 +76,110 @@
&.no-comment
display: none
.shuoshuo-navigation
display: flex
justify-content: center
align-items: center
margin-top: 20px
padding: 20px 0
button
display: flex
justify-content: center
align-items: center
width: 2.7em
height: 2.7em
background-color: var(--btn-bg)
color: var(--btn-color)
font-size: .9em
line-height: 2.5em
transition: all .2s ease-in-out
addBorderRadius(6)
&:not(:disabled)
@extend .btn-effects
&:hover:not(:disabled)
background-color: var(--btn-hover-color)
&:disabled
background: #f5f5f5
color: #ccc
opacity: .5
cursor: not-allowed
.shuoshuo-page-info
margin: 0 15px
color: #858585
white-space: nowrap
font-size: .9em
.shuoshuo-page-input
margin-right: 12px
padding: 0 15px
height: 2.7em
border: 1px solid var(--btn-bg)
background: var(--card-bg)
color: #858585
text-align: center
font-size: .9em
transition: all .2s ease-in-out
addBorderRadius(6)
&:focus
outline: none
border-width: 2px
&::placeholder
color: transparent
/* number */
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button
margin: 0
-webkit-appearance: none
/* Firefox */
-moz-appearance: textfield
/* */
&.shuoshuo-page-num
min-width: 40px
width: 40px
border: none
background: $light-blue
color: var(--white)
font-weight: 500
cursor: text
&:focus
border: 1px solid $light-blue
background: var(--white)
color: #333
/* */
&.invalid
border-color: #ff4757
background-color: #ffeaea
color: #ff4757
animation: shake .5s ease-in-out
/* */
@keyframes shake
0%,
100%
transform: translateX(0)
10%,
30%,
50%,
70%,
90%
transform: translateX(-2px)
20%,
40%,
60%,
80%
transform: translateX(2px)
+3 -83
View File
@@ -1,93 +1,13 @@
#algolia-search
.search-dialog
.ais-SearchBox
input
padding: 5px 14px
width: 100%
outline: none
border: 2px solid $search-color
border-radius: 40px
background: var(--search-bg)
color: var(--search-input-color)
.ais-SearchBox-loadingIndicator
position: absolute
top: 18px
left: 67px
.ais-Hits-list
margin: 0
padding: 0
@extend .list-beauty
a
color: var(--search-a-color)
&:hover
color: $search-color
mark
background: transparent
color: $search-keyword-highlight
font-weight: bold
.algolia-hits-item-title
font-weight: 600
.algolia-hit-item-content
margin: 0 0 8px
word-break: break-word
.ais-Pagination
margin: 15px 0 0
padding: 0
text-align: center
.ais-Pagination-list
margin: 0
padding: 0
list-style: none
.ais-Pagination-item
display: inline
margin: 0 4px
padding: 0
.ais-Pagination-link
display: inline-block
min-width: 24px
height: 24px
text-align: center
line-height: 24px
addBorderRadius()
.ais-Pagination-item--selected
a
background: $theme-paginator-color
color: #eee
cursor: default
.ais-Pagination-item--disabled
visibility: hidden
#algolia-hits
> div
overflow-y: overlay
margin: 0 -20px
padding: 0 22px
max-height: calc(80vh - 220px)
+maxWidth768()
max-height: none
height: calc(var(--search-height) - 235px)
+maxWidth768()
min-height: calc(var(--search-height) - 245px)
#algolia-info
div
display: inline
.algolia-poweredBy
float: right
vertical-align: text-top
padding-top: 2px
svg
height: 1.1em
+188 -5
View File
@@ -20,22 +20,173 @@
border-radius: 0
.search-nav
margin: 0 0 14px
display: flex
justify-content: space-between
align-items: center
margin-bottom: 14px
color: $search-color
font-size: 1.4em
line-height: 1
.search-dialog-title
margin-right: 10px
margin-right: 4px
#loading-status
&[hidden]
display: none !important
.search-close-button
float: right
flex: 1
color: $grey
transition: color .2s ease-in-out
text-align: right
transition: all .2s ease
&:hover
color: $search-color
.local-search-input,
#algolia-search-input
margin: 0 auto
max-width: 100%
width: 100%
input,
.ais-SearchBox-input
padding: 5px 14px
width: 100%
outline: none
border: 2px solid $search-color
border-radius: 40px
background: var(--search-bg)
color: var(--search-input-color)
-webkit-appearance: none
&::placeholder
color: var(--text-color)
.search-result-list,
.ais-Hits-list
overflow-y: overlay
margin: 0 -20px
padding: 0 22px
max-height: calc(80vh - 220px)
.local-search-hit-item,
.ais-Hits-item
display: flex
align-items: flex-start
margin: 3px 0
line-height: 1.8
transition: all .2s ease-in-out
&:hover
transform: translateY(-1px)
&:not([value])::before
display: none
&[value]::before
display: inline-flex
flex-shrink: 0
justify-content: center
align-items: center
margin-right: 6px
min-width: 24px
color: $search-color
content: attr(value) '.'
font-weight: bold
font-style: italic
font-size: .9em
&::marker
content: none
a
flex: 1
color: var(--search-a-color)
&:hover
color: $search-color
.search-result-title,
.algolia-hits-item-title
font-weight: 600
.search-result,
.algolia-hit-item-content
margin: 0 0 8px
word-break: break-all
font-size: .9em
.ais-Pagination
margin: 15px 0 0
padding: 0
text-align: center
.ais-Pagination-list
display: flex
flex-wrap: wrap
justify-content: center
align-items: center
margin: 0
padding: 0
list-style: none
gap: 6px
.ais-Pagination-item
display: flex
padding: 0
&:not(.ais-Pagination-item--selected):not(.ais-Pagination-item--ellipsis):not(.ais-Pagination-item--disabled)
.ais-Pagination-link:hover
background: var(--btn-hover-color)
transform: translateY(-1px)
.ais-Pagination-link
display: inline-flex
justify-content: center
align-items: center
padding: 4px 8px
min-width: 28px
height: 28px
border-radius: 6px
background: var(--btn-bg)
color: var(--btn-color)
transition: all .2s ease
&.ais-Pagination-link--disabled
opacity: .3
cursor: not-allowed
i
font-size: 12px
.ais-Pagination-item--selected
.ais-Pagination-link
background: $theme-paginator-color
font-weight: 600
cursor: default
.ais-Pagination-item--ellipsis
.ais-Pagination-link
padding: 4px 2px
border: none
background: transparent
color: var(--text-color)
cursor: default
&:hover
background: transparent
transform: none
.ais-Pagination-item--disabled
.ais-Pagination-link
opacity: .4
+maxWidth768()
.ais-Pagination-list
gap: 4px
hr
margin: 15px auto
@extend .custom-hr
@@ -50,6 +201,38 @@
display: none
background: rgba($dark-black, .6)
.search-result-stats,
.ais-Stats-text
margin: 15px 0 0
color: var(--text-color)
text-align: center
font-size: .9em
.search-keyword
background: transparent
color: $search-keyword-highlight
font-weight: 600
.search-loading
display: flex
justify-content: center
align-items: center
padding: 20px
color: var(--text-color)
&::before
width: 16px
height: 16px
border: 2px solid var(--text-color)
border-top-color: transparent
border-radius: 50%
content: ''
animation: spin 1s linear infinite
@keyframes spin
to
transform: rotate(360deg)
if hexo-config('search.use') == 'algolia_search'
@require 'algolia'
else if hexo-config('search.use') == 'local_search'
+13 -52
View File
@@ -1,57 +1,18 @@
#local-search
.search-dialog
.local-search-box
margin: 0 auto
max-width: 100%
width: 100%
input
padding: 5px 14px
width: 100%
outline: none
border: 2px solid $search-color
border-radius: 40px
background: var(--search-bg)
color: var(--search-input-color)
-webkit-appearance: none
.search-wrap
display: none
.local-search-hit-item
margin-left: 24px
padding-left: 3px
line-height: 1.8
&::marker
color: $search-color
font-weight: bold
font-style: italic
a
color: var(--search-a-color)
&:hover
color: $search-color
.search-result-title
font-weight: 600
.search-result
margin: 0 0 8px
word-break: break-all
font-size: .9em
.search-result-list
overflow-y: overlay
margin: 0 -20px
padding: 0 22px
max-height: calc(80vh - 180px)
+maxWidth768()
max-height: calc(var(--search-height) - 190px) !important
if hexo-config('search.local_search.pagination.enable')
min-height: calc(var(--search-height) - 255px) !important
else
max-height: calc(var(--search-height) - 200px) !important
.search-keyword
background: transparent
color: $search-keyword-highlight
font-weight: 600
#local-search-stats
.search-result-stats
text-align: left
.search-keyword
font-weight: 600
#loading-database ~ *
visibility: hidden
+1 -1
View File
@@ -698,7 +698,7 @@ document.addEventListener('DOMContentLoaded', () => {
const addCopyright = () => {
const { limitCount, languages } = GLOBAL_CONFIG.copyright
const handleCopy = (e) => {
const handleCopy = e => {
e.preventDefault()
const copyFont = window.getSelection(0).toString()
let textFont = copyFont
+469 -81
View File
@@ -26,7 +26,12 @@ window.addEventListener('load', () => {
const openSearch = () => {
btf.overflowPaddingR.add()
animateElements(true)
setTimeout(() => { document.querySelector('#algolia-search .ais-SearchBox-input').focus() }, 100)
showLoading(false)
setTimeout(() => {
const searchInput = document.querySelector('#algolia-search-input .ais-SearchBox-input')
if (searchInput) searchInput.focus()
}, 100)
const handleEscape = event => {
if (event.code === 'Escape') {
@@ -55,9 +60,38 @@ window.addEventListener('load', () => {
document.querySelector('#algolia-search .search-close-button').addEventListener('click', closeSearch)
}
const cutContent = (content) => {
const cutContent = content => {
if (!content) return ''
const firstOccur = content.indexOf('<mark>')
let contentStr = ''
if (typeof content === 'string') {
contentStr = content.trim()
} else if (typeof content === 'object') {
if (content.value !== undefined) {
contentStr = String(content.value).trim()
if (!contentStr) return ''
} else if (content.matchedWords || content.matchLevel || content.fullyHighlighted !== undefined) {
return ''
} else {
try {
contentStr = JSON.stringify(content).trim()
if (contentStr === '{}' || contentStr === '[]' || contentStr === '""') {
return ''
}
} catch (e) {
return ''
}
}
} else if (content.toString && typeof content.toString === 'function') {
contentStr = content.toString().trim()
if (contentStr === '[object Object]' || contentStr === '[object Array]') {
return ''
}
} else {
return ''
}
const firstOccur = contentStr.indexOf('<mark>')
let start = firstOccur - 30
let end = firstOccur + 120
let pre = ''
@@ -70,94 +104,454 @@ window.addEventListener('load', () => {
pre = '...'
}
if (end > content.length) {
end = content.length
if (end > contentStr.length) {
end = contentStr.length
} else {
post = '...'
}
return `${pre}${content.substring(start, end)}${post}`
// Ensure we don't cut off HTML tags in the middle
let substr = contentStr.substring(start, end)
// Handle tag completeness
// Check for incomplete opening tags at the beginning
const firstCloseBracket = substr.indexOf('>')
const firstOpenBracket = substr.indexOf('<')
// If there's a closing bracket but no opening bracket before it, we've cut a tag
if (firstCloseBracket !== -1 && (firstOpenBracket === -1 || firstCloseBracket < firstOpenBracket)) {
substr = substr.substring(firstCloseBracket + 1)
}
// Check for incomplete closing tags at the end
const lastOpenBracket = substr.lastIndexOf('<')
const lastCloseBracket = substr.lastIndexOf('>')
// If there's an opening bracket after the last closing bracket, we've cut a tag
if (lastOpenBracket !== -1 && lastOpenBracket > lastCloseBracket) {
substr = substr.substring(0, lastOpenBracket)
}
// Balance tags in the substring
const tagStack = []
let balancedStr = ''
let i = 0
while (i < substr.length) {
if (substr[i] === '<') {
// Check if it's a closing tag
if (substr[i + 1] === '/') {
const closeTagEnd = substr.indexOf('>', i)
if (closeTagEnd !== -1) {
const closeTagName = substr.substring(i + 2, closeTagEnd)
// Remove matching opening tag from stack
for (let j = tagStack.length - 1; j >= 0; j--) {
if (tagStack[j] === closeTagName) {
tagStack.splice(j, 1)
break
}
}
balancedStr += substr.substring(i, closeTagEnd + 1)
i = closeTagEnd + 1
continue
}
} else if (substr.substr(i, 2) === '<!' || (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)
balancedStr += substr.substring(i, tagEnd + 1)
i = tagEnd + 1
continue
}
}
}
balancedStr += substr[i]
i++
}
// Close any unclosed tags
while (tagStack.length > 0) {
const tagName = tagStack.pop()
balancedStr += `</${tagName}>`
}
// If we removed content from the beginning, add prefix
if (start > 0 || pre) {
const actualFirstOpenBracket = contentStr.indexOf('<', start > 0 ? start - 30 : 0)
const actualFirstMark = contentStr.indexOf('<mark>', start > 0 ? start - 30 : 0)
if (actualFirstOpenBracket !== -1 &&
(actualFirstMark === -1 || actualFirstOpenBracket < actualFirstMark)) {
pre = '...'
}
}
substr = balancedStr
return `${pre}${substr}${post}`
}
const disableDiv = [
document.getElementById('algolia-hits'),
document.getElementById('algolia-pagination'),
document.querySelector('#algolia-info .algolia-stats')
]
// Helper function to handle Algolia highlight results
const extractHighlightValue = highlightObj => {
if (!highlightObj) return ''
const searchClient = typeof algoliasearch === 'function' ? algoliasearch : window['algoliasearch/lite'].liteClient
const search = instantsearch({
indexName,
searchClient: searchClient(appId, apiKey),
searchFunction (helper) {
disableDiv.forEach(item => {
item.style.display = helper.state.query ? '' : 'none'
})
if (helper.state.query) helper.search()
if (typeof highlightObj === 'string') {
return highlightObj.trim()
}
})
const widgets = [
instantsearch.widgets.configure({ hitsPerPage }),
instantsearch.widgets.searchBox({
container: '#algolia-search-input',
showReset: false,
showSubmit: false,
placeholder: languages.input_placeholder,
showLoadingIndicator: true
}),
instantsearch.widgets.hits({
container: '#algolia-hits',
templates: {
item (data) {
const link = data.permalink || (GLOBAL_CONFIG.root + data.path)
const result = data._highlightResult
const content = result.contentStripTruncate
? cutContent(result.contentStripTruncate.value)
: result.contentStrip
? cutContent(result.contentStrip.value)
: result.content
? cutContent(result.content.value)
: ''
return `
<a href="${link}" class="algolia-hit-item-link">
<span class="algolia-hits-item-title">${result.title.value || 'no-title'}</span>
${content ? `<div class="algolia-hit-item-content">${content}</div>` : ''}
</a>`
},
empty (data) {
return `<div id="algolia-hits-empty">${languages.hits_empty.replace(/\$\{query}/, data.query)}</div>`
if (typeof highlightObj === 'object' && highlightObj.value !== undefined) {
return String(highlightObj.value).trim()
}
return ''
}
// Initialize Algolia client
let searchClient
if (window['algoliasearch/lite'] && typeof window['algoliasearch/lite'].liteClient === 'function') {
searchClient = window['algoliasearch/lite'].liteClient(appId, apiKey)
} else if (typeof window.algoliasearch === 'function') {
searchClient = window.algoliasearch(appId, apiKey)
} else {
return console.error('Algolia search client not found!')
}
if (!searchClient) {
return console.error('Failed to initialize Algolia search client')
}
// Search state
let currentQuery = ''
// Show loading state
const showLoading = show => {
const loadingIndicator = document.getElementById('loading-status')
if (loadingIndicator) {
loadingIndicator.hidden = !show
}
}
// Cache frequently used elements
const elements = {
get searchInput () { return document.querySelector('#algolia-search-input .ais-SearchBox-input') },
get hits () { return document.getElementById('algolia-hits') },
get hitsEmpty () { return document.getElementById('algolia-hits-empty') },
get hitsList () { return document.querySelector('#algolia-hits .ais-Hits-list') },
get hitsWrapper () { return document.querySelector('#algolia-hits .ais-Hits') },
get pagination () { return document.getElementById('algolia-pagination') },
get paginationList () { return document.querySelector('#algolia-pagination .ais-Pagination-list') },
get stats () { return document.querySelector('#algolia-info .ais-Stats-text') },
}
// Show/hide search results area
const toggleResultsVisibility = hasResults => {
elements.pagination.style.display = hasResults ? '' : 'none'
elements.stats.style.display = hasResults ? '' : 'none'
}
// Render search results
const renderHits = (hits, query, page = 0) => {
if (hits.length === 0 && query) {
elements.hitsEmpty.textContent = languages.hits_empty.replace(/\$\{query}/, query)
elements.hitsEmpty.style.display = ''
elements.hitsWrapper.style.display = 'none'
elements.stats.style.display = 'none'
return
}
elements.hitsEmpty.style.display = 'none'
const hitsHTML = hits.map((hit, index) => {
const itemNumber = page * hitsPerPage + index + 1
const link = hit.permalink || (GLOBAL_CONFIG.root + hit.path)
const result = hit._highlightResult || hit
// Content extraction
let content = ''
try {
if (result.contentStripTruncate) {
content = cutContent(result.contentStripTruncate)
} else if (result.contentStrip) {
content = cutContent(result.contentStrip)
} else if (result.content) {
content = cutContent(result.content)
} else if (hit.contentStripTruncate) {
content = cutContent(hit.contentStripTruncate)
} else if (hit.contentStrip) {
content = cutContent(hit.contentStrip)
} else if (hit.content) {
content = cutContent(hit.content)
}
} catch (error) {
content = ''
}
}),
instantsearch.widgets.stats({
container: '#algolia-info > .algolia-stats',
templates: {
text (data) {
const stats = languages.hits_stats
.replace(/\$\{hits}/, data.nbHits)
.replace(/\$\{time}/, data.processingTimeMS)
return `<hr>${stats}`
// Title handling
let title = 'no-title'
try {
if (result.title) {
title = extractHighlightValue(result.title) || 'no-title'
} else if (hit.title) {
title = extractHighlightValue(hit.title) || 'no-title'
}
if (!title || title === 'no-title') {
if (typeof hit.title === 'string' && hit.title.trim()) {
title = hit.title.trim()
} else if (hit.title && typeof hit.title === 'object' && hit.title.value) {
title = String(hit.title.value).trim() || 'no-title'
} else {
title = 'no-title'
}
}
} catch (error) {
title = 'no-title'
}
}),
instantsearch.widgets.poweredBy({
container: '#algolia-info > .algolia-poweredBy'
}),
instantsearch.widgets.pagination({
container: '#algolia-pagination',
totalPages: 5,
templates: {
first: '<i class="fas fa-angle-double-left"></i>',
last: '<i class="fas fa-angle-double-right"></i>',
previous: '<i class="fas fa-angle-left"></i>',
next: '<i class="fas fa-angle-right"></i>'
return `
<li class="ais-Hits-item" value="${itemNumber}">
<a href="${link}" class="algolia-hit-item-link">
<span class="algolia-hits-item-title">${title}</span>
${content ? `<div class="algolia-hit-item-content">${content}</div>` : ''}
</a>
</li>`
}).join('')
elements.hitsList.innerHTML = hitsHTML
elements.hitsWrapper.style.display = query ? '' : 'none'
if (hits.length > 0) {
elements.stats.style.display = ''
}
}
// Render pagination
const renderPagination = (page, nbPages) => {
if (nbPages <= 1) {
elements.pagination.style.display = 'none'
elements.paginationList.innerHTML = ''
return
}
elements.pagination.style.display = 'block'
const isFirstPage = page === 0
const isLastPage = page === nbPages - 1
// Responsive page display
const isMobile = window.innerWidth < 768
const maxVisiblePages = isMobile ? 3 : 5
let startPage = Math.max(0, page - Math.floor(maxVisiblePages / 2))
const endPage = Math.min(nbPages - 1, startPage + maxVisiblePages - 1)
// Adjust starting page to maintain max visible pages
if (endPage - startPage + 1 < maxVisiblePages) {
startPage = Math.max(0, endPage - maxVisiblePages + 1)
}
let pagesHTML = ''
// Only add ellipsis and first page when there are many pages
if (nbPages > maxVisiblePages && startPage > 0) {
pagesHTML += `
<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) {
pagesHTML += `
<li class="ais-Pagination-item ais-Pagination-item--ellipsis">
<span class="ais-Pagination-link">...</span>
</li>`
}
}
// Add middle page numbers
for (let i = startPage; i <= endPage; i++) {
const isSelected = i === page
if (isSelected) {
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 {
pagesHTML += `
<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
if (nbPages > maxVisiblePages && endPage < nbPages - 1) {
if (endPage < nbPages - 2) {
pagesHTML += `
<li class="ais-Pagination-item ais-Pagination-item--ellipsis">
<span class="ais-Pagination-link">...</span>
</li>`
}
pagesHTML += `
<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) {
elements.paginationList.innerHTML = `
<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>'
: `<a class="ais-Pagination-link" aria-label="Previous Page" href="#" data-page="${page - 1}"><i class="fas fa-angle-left"></i></a>`
}
</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>'
: `<a class="ais-Pagination-link" aria-label="Next Page" href="#" data-page="${page + 1}"><i class="fas fa-angle-right"></i></a>`
}
</li>`
elements.pagination.style.display = currentQuery ? '' : 'none'
} else {
elements.pagination.style.display = 'none'
}
}
// Render statistics
const renderStats = (nbHits, processingTimeMS, query) => {
if (query) {
const stats = languages.hits_stats
.replace(/\$\{hits}/, nbHits)
.replace(/\$\{time}/, processingTimeMS)
elements.stats.innerHTML = `<hr>${stats}`
elements.stats.style.display = ''
} else {
elements.stats.style.display = 'none'
}
}
// Perform search
const performSearch = async (query, page = 0) => {
if (!query.trim()) {
currentQuery = ''
renderHits([], '', 0)
renderPagination(0, 0)
renderStats(0, 0, '')
toggleResultsVisibility(false)
return
}
showLoading(true)
currentQuery = query
try {
let result
if (searchClient && typeof searchClient.search === 'function') {
// v5 multi-index search
const searchResult = await searchClient.search([{
indexName,
query,
params: {
page,
hitsPerPage,
highlightPreTag: '<mark>',
highlightPostTag: '</mark>',
attributesToHighlight: ['title', 'content', 'contentStrip', 'contentStripTruncate']
}
}])
result = searchResult.results[0]
} else if (searchClient && typeof searchClient.initIndex === 'function') {
// v4 single-index search
const index = searchClient.initIndex(indexName)
result = await index.search(query, {
page,
hitsPerPage,
highlightPreTag: '<mark>',
highlightPostTag: '</mark>',
attributesToHighlight: ['title', 'content', 'contentStrip', 'contentStripTruncate']
})
} else {
throw new Error('Algolia: No compatible search method available')
}
renderHits(result.hits || [], query, page)
const actualNbPages = result.nbHits <= hitsPerPage ? 1 : (result.nbPages || 0)
renderPagination(page, actualNbPages)
renderStats(result.nbHits || 0, result.processingTimeMS || 0, query)
const hasResults = result.hits && result.hits.length > 0
toggleResultsVisibility(hasResults)
// Refresh Pjax links
if (window.pjax) {
window.pjax.refresh(document.getElementById('algolia-hits'))
}
} catch (error) {
console.error('Algolia search error:', error)
renderHits([], query, page)
renderPagination(0, 0)
renderStats(0, 0, query)
} finally {
showLoading(false)
}
}
// Debounced search
let searchTimeout
const debouncedSearch = (query, delay = 300) => {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => performSearch(query), delay)
}
// Initialize search box and events
const initializeSearch = () => {
showLoading(false)
if (elements.searchInput) {
elements.searchInput.addEventListener('input', e => {
const query = e.target.value
debouncedSearch(query)
})
}
const searchForm = document.querySelector('#algolia-search-input .ais-SearchBox-form')
if (searchForm) {
searchForm.addEventListener('submit', e => {
e.preventDefault()
const query = elements.searchInput.value
performSearch(query)
})
}
// Pagination event delegation
elements.pagination.addEventListener('click', e => {
e.preventDefault()
const link = e.target.closest('a[data-page]')
if (link) {
const page = parseInt(link.dataset.page, 10)
if (!isNaN(page) && currentQuery) {
performSearch(currentQuery, page)
}
}
})
]
search.addWidgets(widgets)
search.start()
// Initial state
toggleResultsVisibility(false)
}
// Initialize
initializeSearch()
searchClickFn()
searchFnOnce()
@@ -165,10 +559,4 @@ window.addEventListener('load', () => {
if (!btf.isHidden($searchMask)) closeSearch()
searchClickFn()
})
if (window.pjax) {
search.on('render', () => {
window.pjax.refresh(document.getElementById('algolia-hits'))
})
}
})
+227 -20
View File
@@ -236,40 +236,229 @@ class LocalSearch {
window.addEventListener('load', () => {
// Search
const { path, top_n_per_article, unescape, languages } = GLOBAL_CONFIG.localSearch
const { path, top_n_per_article, unescape, languages, pagination } = GLOBAL_CONFIG.localSearch
const enablePagination = pagination && pagination.enable
const localSearch = new LocalSearch({
path,
top_n_per_article,
unescape
})
const input = document.querySelector('#local-search-input input')
const statsItem = document.getElementById('local-search-stats-wrap')
const input = document.querySelector('.local-search-input input')
const statsItem = document.getElementById('local-search-stats')
const $loadingStatus = document.getElementById('loading-status')
const isXml = !path.endsWith('json')
// Pagination variables (only initialize if pagination is enabled)
let currentPage = 0
const hitsPerPage = pagination.hitsPerPage || 10
let currentResultItems = []
if (!enablePagination) {
// If pagination is disabled, we don't need these variables
currentPage = undefined
currentResultItems = undefined
}
// Cache frequently used elements
const elements = {
get pagination () { return document.getElementById('local-search-pagination') },
get paginationList () { return document.querySelector('#local-search-pagination .ais-Pagination-list') }
}
// Show/hide search results area
const toggleResultsVisibility = hasResults => {
if (enablePagination) {
elements.pagination.style.display = hasResults ? '' : 'none'
} else {
elements.pagination.style.display = 'none'
}
}
// Render search results for current page
const renderResults = (searchText, resultItems) => {
const container = document.getElementById('local-search-results')
// Determine items to display based on pagination mode
const itemsToDisplay = enablePagination
? currentResultItems.slice(currentPage * hitsPerPage, (currentPage + 1) * hitsPerPage)
: resultItems
// Handle empty page in pagination mode
if (enablePagination && itemsToDisplay.length === 0 && currentResultItems.length > 0) {
currentPage = 0
renderResults(searchText, resultItems)
return
}
// Add numbering to items
const numberedItems = itemsToDisplay.map((result, index) => {
const itemNumber = enablePagination
? currentPage * hitsPerPage + index + 1
: index + 1
return result.item.replace(
'<li class="local-search-hit-item">',
`<li class="local-search-hit-item" value="${itemNumber}">`
)
})
container.innerHTML = `<ol class="search-result-list">${numberedItems.join('')}</ol>`
// Update stats
const displayCount = enablePagination ? currentResultItems.length : resultItems.length
const stats = languages.hits_stats.replace(/\$\{hits}/, displayCount)
statsItem.innerHTML = `<hr><div class="search-result-stats">${stats}</div>`
// Handle pagination
if (enablePagination) {
const nbPages = Math.ceil(currentResultItems.length / hitsPerPage)
renderPagination(currentPage, nbPages, searchText)
}
const hasResults = resultItems.length > 0
toggleResultsVisibility(hasResults)
window.pjax && window.pjax.refresh(container)
}
// Render pagination
const renderPagination = (page, nbPages, query) => {
if (nbPages <= 1) {
elements.pagination.style.display = 'none'
elements.paginationList.innerHTML = ''
return
}
elements.pagination.style.display = 'block'
const isFirstPage = page === 0
const isLastPage = page === nbPages - 1
// Responsive page display
const isMobile = window.innerWidth < 768
const maxVisiblePages = isMobile ? 3 : 5
let startPage = Math.max(0, page - Math.floor(maxVisiblePages / 2))
const endPage = Math.min(nbPages - 1, startPage + maxVisiblePages - 1)
// Adjust starting page to maintain max visible pages
if (endPage - startPage + 1 < maxVisiblePages) {
startPage = Math.max(0, endPage - maxVisiblePages + 1)
}
let pagesHTML = ''
// Only add ellipsis and first page when there are many pages
if (nbPages > maxVisiblePages && startPage > 0) {
pagesHTML += `
<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) {
pagesHTML += `
<li class="ais-Pagination-item ais-Pagination-item--ellipsis">
<span class="ais-Pagination-link">...</span>
</li>`
}
}
// Add middle page numbers
for (let i = startPage; i <= endPage; i++) {
const isSelected = i === page
if (isSelected) {
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 {
pagesHTML += `
<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
if (nbPages > maxVisiblePages && endPage < nbPages - 1) {
if (endPage < nbPages - 2) {
pagesHTML += `
<li class="ais-Pagination-item ais-Pagination-item--ellipsis">
<span class="ais-Pagination-link">...</span>
</li>`
}
pagesHTML += `
<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) {
elements.paginationList.innerHTML = `
<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>'
: `<a class="ais-Pagination-link" aria-label="Previous Page" href="#" data-page="${page - 1}"><i class="fas fa-angle-left"></i></a>`
}
</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>'
: `<a class="ais-Pagination-link" aria-label="Next Page" href="#" data-page="${page + 1}"><i class="fas fa-angle-right"></i></a>`
}
</li>`
} else {
elements.pagination.style.display = 'none'
}
}
// Clear search results and stats
const clearSearchResults = () => {
const container = document.getElementById('local-search-results')
container.textContent = ''
statsItem.textContent = ''
toggleResultsVisibility(false)
if (enablePagination) {
currentResultItems = []
currentPage = 0
}
}
// Show no results message
const showNoResults = searchText => {
const container = document.getElementById('local-search-results')
container.textContent = ''
const statsDiv = document.createElement('div')
statsDiv.className = 'search-result-stats'
statsDiv.textContent = languages.hits_empty.replace(/\$\{query}/, searchText)
statsItem.innerHTML = statsDiv.outerHTML
toggleResultsVisibility(false)
if (enablePagination) {
currentResultItems = []
currentPage = 0
}
}
const inputEventFunction = () => {
if (!localSearch.isfetched) return
let searchText = input.value.trim().toLowerCase()
isXml && (searchText = searchText.replace(/</g, '&lt;').replace(/>/g, '&gt;'))
if (searchText !== '') $loadingStatus.innerHTML = '<i class="fas fa-spinner fa-pulse"></i>'
if (searchText !== '') $loadingStatus.hidden = false
const keywords = searchText.split(/[-\s]+/)
const container = document.getElementById('local-search-results')
let resultItems = []
if (searchText.length > 0) {
// Perform local searching
resultItems = localSearch.getResultItems(keywords)
}
if (keywords.length === 1 && keywords[0] === '') {
container.textContent = ''
statsItem.textContent = ''
clearSearchResults()
} else if (resultItems.length === 0) {
container.textContent = ''
const statsDiv = document.createElement('div')
statsDiv.className = 'search-result-stats'
statsDiv.textContent = languages.hits_empty.replace(/\$\{query}/, searchText)
statsItem.innerHTML = statsDiv.outerHTML
showNoResults(searchText)
} else {
// Sort results by relevance
resultItems.sort((left, right) => {
if (left.includedCount !== right.includedCount) {
return right.includedCount - left.includedCount
@@ -279,14 +468,14 @@ window.addEventListener('load', () => {
return right.id - left.id
})
const stats = languages.hits_stats.replace(/\$\{hits}/, resultItems.length)
container.innerHTML = `<ol class="search-result-list">${resultItems.map(result => result.item).join('')}</ol>`
statsItem.innerHTML = `<hr><div class="search-result-stats">${stats}</div>`
window.pjax && window.pjax.refresh(container)
if (enablePagination) {
currentResultItems = resultItems
currentPage = 0
}
renderResults(searchText, resultItems)
}
$loadingStatus.textContent = ''
$loadingStatus.hidden = true
}
let loadFlag = false
@@ -340,11 +529,29 @@ window.addEventListener('load', () => {
localSearch.fetchData()
}
localSearch.highlightSearchWords(document.getElementById('article-container'))
// Pagination event delegation - only add if pagination is enabled
if (enablePagination) {
elements.pagination.addEventListener('click', e => {
e.preventDefault()
const link = e.target.closest('a[data-page]')
if (link) {
const page = parseInt(link.dataset.page, 10)
if (!isNaN(page) && currentResultItems.length > 0) {
currentPage = page
renderResults(input.value.trim().toLowerCase(), currentResultItems)
}
}
})
}
// Initial state
toggleResultsVisibility(false)
}
window.addEventListener('search:loaded', () => {
const $loadDataItem = document.getElementById('loading-database')
$loadDataItem.nextElementSibling.style.display = 'block'
$loadDataItem.nextElementSibling.style.visibility = 'visible'
$loadDataItem.remove()
})
+4 -4
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -106,7 +106,7 @@
loadComment: (dom, callback) => {
if ('IntersectionObserver' in window) {
const observerItem = new IntersectionObserver((entries) => {
const observerItem = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) {
callback()
observerItem.disconnect()