Site updated: 2026-05-13 16:50:34
This commit is contained in:
930
js/main.js
Normal file
930
js/main.js
Normal file
@@ -0,0 +1,930 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
let headerContentWidth, $nav
|
||||
let mobileSidebarOpen = false
|
||||
|
||||
const adjustMenu = init => {
|
||||
const getAllWidth = ele => Array.from(ele).reduce((width, i) => width + i.offsetWidth, 0)
|
||||
|
||||
if (init) {
|
||||
const blogInfoWidth = getAllWidth(document.querySelector('#blog-info > a').children)
|
||||
const menusWidth = getAllWidth(document.getElementById('menus').children)
|
||||
headerContentWidth = blogInfoWidth + menusWidth
|
||||
$nav = document.getElementById('nav')
|
||||
}
|
||||
|
||||
const hideMenuIndex = window.innerWidth <= 768 || headerContentWidth > $nav.offsetWidth - 120
|
||||
$nav.classList.toggle('hide-menu', hideMenuIndex)
|
||||
}
|
||||
|
||||
// 初始化header
|
||||
const initAdjust = () => {
|
||||
adjustMenu(true)
|
||||
$nav.classList.add('show')
|
||||
}
|
||||
|
||||
// sidebar menus
|
||||
const sidebarFn = {
|
||||
open: () => {
|
||||
btf.overflowPaddingR.add()
|
||||
btf.animateIn(document.getElementById('menu-mask'), 'to_show 0.5s')
|
||||
document.getElementById('sidebar-menus').classList.add('open')
|
||||
mobileSidebarOpen = true
|
||||
},
|
||||
close: () => {
|
||||
btf.overflowPaddingR.remove()
|
||||
btf.animateOut(document.getElementById('menu-mask'), 'to_hide 0.5s')
|
||||
document.getElementById('sidebar-menus').classList.remove('open')
|
||||
mobileSidebarOpen = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 首頁top_img底下的箭頭
|
||||
*/
|
||||
const scrollDownInIndex = () => {
|
||||
const handleScrollToDest = () => {
|
||||
btf.scrollToDest(document.getElementById('content-inner').offsetTop, 300)
|
||||
}
|
||||
|
||||
const $scrollDownEle = document.getElementById('scroll-down')
|
||||
$scrollDownEle && btf.addEventListenerPjax($scrollDownEle, 'click', handleScrollToDest)
|
||||
}
|
||||
|
||||
/**
|
||||
* 代碼
|
||||
* 只適用於Hexo默認的代碼渲染
|
||||
*/
|
||||
const addHighlightTool = () => {
|
||||
const highLight = GLOBAL_CONFIG.highlight
|
||||
if (!highLight) return
|
||||
|
||||
const { highlightCopy, highlightLang, highlightHeightLimit, highlightFullpage, highlightMacStyle, plugin } = highLight
|
||||
const isHighlightShrink = GLOBAL_CONFIG_SITE.isHighlightShrink
|
||||
const isShowTool = highlightCopy || highlightLang || isHighlightShrink !== undefined || highlightFullpage || highlightMacStyle
|
||||
const $figureHighlight = plugin === 'highlight.js' ? document.querySelectorAll('figure.highlight') : document.querySelectorAll('pre[class*="language-"]')
|
||||
|
||||
if (!((isShowTool || highlightHeightLimit) && $figureHighlight.length)) return
|
||||
|
||||
const isPrismjs = plugin === 'prismjs'
|
||||
const highlightShrinkClass = isHighlightShrink === true ? 'closed' : ''
|
||||
const highlightShrinkEle = isHighlightShrink !== undefined ? '<i class="fas fa-angle-down expand"></i>' : ''
|
||||
const highlightCopyEle = highlightCopy ? '<div class="copy-notice"></div><i class="fas fa-paste copy-button"></i>' : ''
|
||||
const highlightMacStyleEle = '<div class="macStyle"><div class="mac-close"></div><div class="mac-minimize"></div><div class="mac-maximize"></div></div>'
|
||||
const highlightFullpageEle = highlightFullpage ? '<i class="fa-solid fa-up-right-and-down-left-from-center fullpage-button"></i>' : ''
|
||||
|
||||
const alertInfo = (ele, text) => {
|
||||
if (GLOBAL_CONFIG.Snackbar !== undefined) {
|
||||
btf.snackbarShow(text)
|
||||
} else {
|
||||
ele.textContent = text
|
||||
ele.style.opacity = 1
|
||||
setTimeout(() => { ele.style.opacity = 0 }, 800)
|
||||
}
|
||||
}
|
||||
|
||||
const copy = async (text, ctx) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
alertInfo(ctx, GLOBAL_CONFIG.copy.success)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy: ', err)
|
||||
alertInfo(ctx, GLOBAL_CONFIG.copy.noSupport)
|
||||
}
|
||||
}
|
||||
|
||||
// click events
|
||||
const highlightCopyFn = (ele, clickEle) => {
|
||||
const $buttonParent = ele.parentNode
|
||||
$buttonParent.classList.add('copy-true')
|
||||
const preCodeSelector = isPrismjs ? 'pre code' : 'table .code pre'
|
||||
const codeElement = $buttonParent.querySelector(preCodeSelector)
|
||||
if (!codeElement) return
|
||||
copy(codeElement.innerText, clickEle.previousElementSibling)
|
||||
$buttonParent.classList.remove('copy-true')
|
||||
}
|
||||
|
||||
const highlightShrinkFn = ele => ele.classList.toggle('closed')
|
||||
|
||||
const codeFullpage = (item, clickEle) => {
|
||||
const wrapEle = item.closest('figure.highlight')
|
||||
const isFullpage = wrapEle.classList.toggle('code-fullpage')
|
||||
|
||||
document.body.style.overflow = isFullpage ? 'hidden' : ''
|
||||
clickEle.classList.toggle('fa-down-left-and-up-right-to-center', isFullpage)
|
||||
clickEle.classList.toggle('fa-up-right-and-down-left-from-center', !isFullpage)
|
||||
}
|
||||
|
||||
const highlightToolsFn = e => {
|
||||
const $target = e.target.classList
|
||||
const currentElement = e.currentTarget
|
||||
if ($target.contains('expand')) highlightShrinkFn(currentElement)
|
||||
else if ($target.contains('copy-button')) highlightCopyFn(currentElement, e.target)
|
||||
else if ($target.contains('fullpage-button')) codeFullpage(currentElement, e.target)
|
||||
}
|
||||
|
||||
const expandCode = e => e.currentTarget.classList.toggle('expand-done')
|
||||
|
||||
// 獲取隱藏狀態下元素的真實高度
|
||||
const getActualHeight = item => {
|
||||
const hiddenElements = new Map()
|
||||
|
||||
const fix = () => {
|
||||
let current = item
|
||||
while (current !== document.body && current != null) {
|
||||
if (window.getComputedStyle(current).display === 'none') {
|
||||
hiddenElements.set(current, current.getAttribute('style') || '')
|
||||
}
|
||||
current = current.parentNode
|
||||
}
|
||||
|
||||
const style = 'visibility: hidden !important; display: block !important;'
|
||||
hiddenElements.forEach((originalStyle, elem) => {
|
||||
elem.setAttribute('style', originalStyle ? originalStyle + ';' + style : style)
|
||||
})
|
||||
}
|
||||
|
||||
const restore = () => {
|
||||
hiddenElements.forEach((originalStyle, elem) => {
|
||||
if (originalStyle === '') elem.removeAttribute('style')
|
||||
else elem.setAttribute('style', originalStyle)
|
||||
})
|
||||
}
|
||||
|
||||
fix()
|
||||
const height = item.offsetHeight
|
||||
restore()
|
||||
return height
|
||||
}
|
||||
|
||||
const createEle = (lang, item) => {
|
||||
const fragment = document.createDocumentFragment()
|
||||
|
||||
if (isShowTool) {
|
||||
const hlTools = document.createElement('div')
|
||||
hlTools.className = `highlight-tools ${highlightShrinkClass}`
|
||||
hlTools.innerHTML = highlightMacStyleEle + highlightShrinkEle + lang + highlightCopyEle + highlightFullpageEle
|
||||
btf.addEventListenerPjax(hlTools, 'click', highlightToolsFn)
|
||||
fragment.appendChild(hlTools)
|
||||
}
|
||||
|
||||
if (highlightHeightLimit && getActualHeight(item) > highlightHeightLimit + 30) {
|
||||
const ele = document.createElement('div')
|
||||
ele.className = 'code-expand-btn'
|
||||
ele.innerHTML = '<i class="fas fa-angle-double-down"></i>'
|
||||
btf.addEventListenerPjax(ele, 'click', expandCode)
|
||||
fragment.appendChild(ele)
|
||||
}
|
||||
|
||||
isPrismjs ? item.parentNode.insertBefore(fragment, item) : item.insertBefore(fragment, item.firstChild)
|
||||
}
|
||||
|
||||
$figureHighlight.forEach(item => {
|
||||
let langName = ''
|
||||
if (isPrismjs) btf.wrap(item, 'figure', { class: 'highlight' })
|
||||
|
||||
if (!highlightLang) {
|
||||
createEle('', item)
|
||||
return
|
||||
}
|
||||
|
||||
if (isPrismjs) {
|
||||
langName = item.getAttribute('data-language') || 'Code'
|
||||
} else {
|
||||
langName = item.getAttribute('class').split(' ')[1]
|
||||
if (langName === 'plain' || langName === undefined) langName = 'Code'
|
||||
}
|
||||
createEle(`<div class="code-lang">${langName}</div>`, item)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PhotoFigcaption
|
||||
*/
|
||||
const addPhotoFigcaption = () => {
|
||||
if (!GLOBAL_CONFIG.isPhotoFigcaption) return
|
||||
document.querySelectorAll('#article-container img').forEach(item => {
|
||||
const altValue = item.title || item.alt
|
||||
if (!altValue) return
|
||||
const ele = document.createElement('div')
|
||||
ele.className = 'img-alt text-center'
|
||||
ele.textContent = altValue
|
||||
item.insertAdjacentElement('afterend', ele)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightbox
|
||||
*/
|
||||
const runLightbox = () => {
|
||||
btf.loadLightbox(document.querySelectorAll('#article-container img:not(.no-lightbox)'))
|
||||
}
|
||||
|
||||
/**
|
||||
* justified-gallery 圖庫排版
|
||||
*/
|
||||
|
||||
const fetchUrl = async url => {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch URL:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const runJustifiedGallery = (container, data, config) => {
|
||||
const { isButton, limit, firstLimit, tabs } = config
|
||||
|
||||
const dataLength = data.length
|
||||
const maxGroupKey = Math.ceil((dataLength - firstLimit) / limit + 1)
|
||||
|
||||
// Gallery configuration
|
||||
const igConfig = {
|
||||
gap: 5,
|
||||
isConstantSize: true,
|
||||
sizeRange: [150, 600],
|
||||
// useResizeObserver: true,
|
||||
// observeChildren: true,
|
||||
useTransform: true
|
||||
// useRecycle: false
|
||||
}
|
||||
|
||||
const ig = new InfiniteGrid.JustifiedInfiniteGrid(container, igConfig)
|
||||
let isLayoutHidden = false
|
||||
|
||||
// Utility functions
|
||||
const sanitizeString = str => (str && str.replace(/"/g, '"')) || ''
|
||||
|
||||
const createImageItem = item => {
|
||||
const alt = item.alt ? `alt="${sanitizeString(item.alt)}"` : ''
|
||||
const title = item.title ? `title="${sanitizeString(item.title)}"` : ''
|
||||
return `<div class="item">
|
||||
<img src="${item.url}" data-grid-maintained-target="true" ${alt} ${title} />
|
||||
</div>`
|
||||
}
|
||||
|
||||
const getItems = (nextGroupKey, count, isFirst = false) => {
|
||||
const startIndex = isFirst ? (nextGroupKey - 1) * count : (nextGroupKey - 2) * count + firstLimit
|
||||
return data.slice(startIndex, startIndex + count).map(createImageItem)
|
||||
}
|
||||
|
||||
// Load more button
|
||||
const addLoadMoreButton = container => {
|
||||
const button = document.createElement('button')
|
||||
button.innerHTML = `${GLOBAL_CONFIG.infinitegrid.buttonText}<i class="fa-solid fa-arrow-down"></i>`
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
button.remove()
|
||||
btf.setLoading.add(container)
|
||||
appendItems(ig.getGroups().length + 1, limit)
|
||||
}, { once: true })
|
||||
|
||||
container.insertAdjacentElement('afterend', button)
|
||||
}
|
||||
|
||||
const appendItems = (nextGroupKey, count, isFirst) => {
|
||||
ig.append(getItems(nextGroupKey, count, isFirst), nextGroupKey)
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
const handleRenderComplete = e => {
|
||||
if (tabs) {
|
||||
const parentNode = container.parentNode
|
||||
if (isLayoutHidden) {
|
||||
parentNode.style.visibility = 'visible'
|
||||
}
|
||||
if (container.offsetHeight === 0) {
|
||||
parentNode.style.visibility = 'hidden'
|
||||
isLayoutHidden = true
|
||||
}
|
||||
}
|
||||
|
||||
const { updated, isResize, mounted } = e
|
||||
if (!updated.length || !mounted.length || isResize) return
|
||||
|
||||
btf.loadLightbox(container.querySelectorAll('img:not(.medium-zoom-image)'))
|
||||
|
||||
if (ig.getGroups().length === maxGroupKey) {
|
||||
btf.setLoading.remove(container)
|
||||
!tabs && ig.off('renderComplete', handleRenderComplete)
|
||||
return
|
||||
}
|
||||
|
||||
if (isButton) {
|
||||
btf.setLoading.remove(container)
|
||||
addLoadMoreButton(container)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRequestAppend = btf.debounce(e => {
|
||||
const nextGroupKey = (+e.groupKey || 0) + 1
|
||||
|
||||
if (nextGroupKey === 1) appendItems(nextGroupKey, firstLimit, true)
|
||||
else appendItems(nextGroupKey, limit)
|
||||
|
||||
if (nextGroupKey === maxGroupKey) ig.off('requestAppend', handleRequestAppend)
|
||||
}, 300)
|
||||
|
||||
btf.setLoading.add(container)
|
||||
ig.on('renderComplete', handleRenderComplete)
|
||||
|
||||
if (isButton) {
|
||||
appendItems(1, firstLimit, true)
|
||||
} else {
|
||||
ig.on('requestAppend', handleRequestAppend)
|
||||
ig.renderItems()
|
||||
}
|
||||
|
||||
btf.addGlobalFn('pjaxSendOnce', () => ig.destroy())
|
||||
}
|
||||
|
||||
const addJustifiedGallery = async (elements, tabs = false) => {
|
||||
if (!elements.length) return
|
||||
|
||||
const initGallery = async () => {
|
||||
for (const element of elements) {
|
||||
if (btf.isHidden(element) || element.classList.contains('loaded')) continue
|
||||
|
||||
const config = {
|
||||
isButton: element.getAttribute('data-button') === 'true',
|
||||
limit: parseInt(element.getAttribute('data-limit'), 10),
|
||||
firstLimit: parseInt(element.getAttribute('data-first'), 10),
|
||||
tabs
|
||||
}
|
||||
|
||||
const container = element.firstElementChild
|
||||
const content = container.textContent
|
||||
container.textContent = ''
|
||||
element.classList.add('loaded')
|
||||
|
||||
try {
|
||||
const data = element.getAttribute('data-type') === 'url' ? await fetchUrl(content) : JSON.parse(content)
|
||||
runJustifiedGallery(container, data, config)
|
||||
} catch (error) {
|
||||
console.error('Gallery data parsing failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof InfiniteGrid === 'function') {
|
||||
await initGallery()
|
||||
} else {
|
||||
await btf.getScript(GLOBAL_CONFIG.infinitegrid.js)
|
||||
await initGallery()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* rightside scroll percent
|
||||
*/
|
||||
const rightsideScrollPercent = currentTop => {
|
||||
const scrollPercent = btf.getScrollPercent(currentTop, document.body)
|
||||
const goUpElement = document.getElementById('go-up')
|
||||
|
||||
if (scrollPercent < 95) {
|
||||
goUpElement.classList.add('show-percent')
|
||||
goUpElement.querySelector('.scroll-percent').textContent = scrollPercent
|
||||
} else {
|
||||
goUpElement.classList.remove('show-percent')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 滾動處理
|
||||
*/
|
||||
const scrollFn = () => {
|
||||
const $rightside = document.getElementById('rightside')
|
||||
const innerHeight = window.innerHeight + 56
|
||||
let initTop = 0
|
||||
const $header = document.getElementById('page-header')
|
||||
const isChatBtn = typeof chatBtn !== 'undefined'
|
||||
const isShowPercent = GLOBAL_CONFIG.percent.rightside
|
||||
|
||||
// 檢查文檔高度是否小於視窗高度
|
||||
const checkDocumentHeight = () => {
|
||||
if (document.body.scrollHeight <= innerHeight) {
|
||||
$rightside.classList.add('rightside-show')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果文檔高度小於視窗高度,直接返回
|
||||
if (checkDocumentHeight()) return
|
||||
|
||||
// find the scroll direction
|
||||
const scrollDirection = currentTop => {
|
||||
const result = currentTop > initTop // true is down & false is up
|
||||
initTop = currentTop
|
||||
return result
|
||||
}
|
||||
|
||||
let flag = ''
|
||||
const scrollTask = btf.throttle(() => {
|
||||
const currentTop = window.scrollY || document.documentElement.scrollTop
|
||||
const isDown = scrollDirection(currentTop)
|
||||
if (currentTop > 56) {
|
||||
if (flag === '') {
|
||||
$header.classList.add('nav-fixed')
|
||||
$rightside.classList.add('rightside-show')
|
||||
}
|
||||
|
||||
if (isDown) {
|
||||
if (flag !== 'down') {
|
||||
$header.classList.remove('nav-visible')
|
||||
isChatBtn && window.chatBtn.hide()
|
||||
flag = 'down'
|
||||
}
|
||||
} else {
|
||||
if (flag !== 'up') {
|
||||
$header.classList.add('nav-visible')
|
||||
isChatBtn && window.chatBtn.show()
|
||||
flag = 'up'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
flag = ''
|
||||
if (currentTop === 0) {
|
||||
$header.classList.remove('nav-fixed', 'nav-visible')
|
||||
}
|
||||
$rightside.classList.remove('rightside-show')
|
||||
}
|
||||
|
||||
isShowPercent && rightsideScrollPercent(currentTop)
|
||||
checkDocumentHeight()
|
||||
}, 300)
|
||||
|
||||
btf.addEventListenerPjax(window, 'scroll', scrollTask, { passive: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* toc,anchor
|
||||
*/
|
||||
const scrollFnToDo = () => {
|
||||
const isToc = GLOBAL_CONFIG_SITE.isToc
|
||||
const isAnchor = GLOBAL_CONFIG.isAnchor
|
||||
const $article = document.getElementById('article-container')
|
||||
|
||||
if (!($article && (isToc || isAnchor))) return
|
||||
|
||||
let $tocLink, $cardToc, autoScrollToc, $tocPercentage, isExpand
|
||||
|
||||
if (isToc) {
|
||||
const $cardTocLayout = document.getElementById('card-toc')
|
||||
$cardToc = $cardTocLayout.querySelector('.toc-content')
|
||||
$tocLink = $cardToc.querySelectorAll('.toc-link')
|
||||
$tocPercentage = $cardTocLayout.querySelector('.toc-percentage')
|
||||
isExpand = $cardToc.classList.contains('is-expand')
|
||||
|
||||
// toc元素點擊
|
||||
const tocItemClickFn = e => {
|
||||
const target = e.target.closest('.toc-link')
|
||||
if (!target) return
|
||||
|
||||
e.preventDefault()
|
||||
btf.scrollToDest(btf.getEleTop(document.getElementById(decodeURI(target.getAttribute('href')).replace('#', ''))), 300)
|
||||
if (window.innerWidth < 900) {
|
||||
$cardTocLayout.classList.remove('open')
|
||||
}
|
||||
}
|
||||
|
||||
btf.addEventListenerPjax($cardToc, 'click', tocItemClickFn)
|
||||
|
||||
autoScrollToc = item => {
|
||||
const sidebarHeight = $cardToc.clientHeight
|
||||
const itemOffsetTop = item.offsetTop
|
||||
const itemHeight = item.clientHeight
|
||||
const scrollTop = $cardToc.scrollTop
|
||||
const offset = itemOffsetTop - scrollTop
|
||||
const middlePosition = (sidebarHeight - itemHeight) / 2
|
||||
|
||||
if (offset !== middlePosition) {
|
||||
$cardToc.scrollTop = scrollTop + (offset - middlePosition)
|
||||
}
|
||||
}
|
||||
|
||||
// 處理 hexo-blog-encrypt 事件
|
||||
$cardToc.style.display = 'block'
|
||||
}
|
||||
|
||||
// find head position & add active class
|
||||
const $articleList = $article.querySelectorAll('h1,h2,h3,h4,h5,h6')
|
||||
let detectItem = ''
|
||||
|
||||
const findHeadPosition = top => {
|
||||
if (top === 0) return false
|
||||
|
||||
let currentId = ''
|
||||
let currentIndex = ''
|
||||
|
||||
for (let i = 0; i < $articleList.length; i++) {
|
||||
const ele = $articleList[i]
|
||||
if (top > btf.getEleTop(ele) - 80) {
|
||||
const id = ele.id
|
||||
currentId = id ? '#' + encodeURI(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) {
|
||||
let parent = currentActive.parentNode
|
||||
while (!parent.matches('.toc')) {
|
||||
if (parent.matches('li')) parent.classList.add('active')
|
||||
parent = parent.parentNode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// main of scroll
|
||||
const tocScrollFn = btf.throttle(() => {
|
||||
const currentTop = window.scrollY || document.documentElement.scrollTop
|
||||
if (isToc && GLOBAL_CONFIG.percent.toc) {
|
||||
$tocPercentage.textContent = btf.getScrollPercent(currentTop, $article)
|
||||
}
|
||||
findHeadPosition(currentTop)
|
||||
}, 100)
|
||||
|
||||
btf.addEventListenerPjax(window, 'scroll', tocScrollFn, { passive: true })
|
||||
}
|
||||
|
||||
const handleThemeChange = mode => {
|
||||
const globalFn = window.globalFn || {}
|
||||
const themeChange = globalFn.themeChange || {}
|
||||
if (!themeChange) {
|
||||
return
|
||||
}
|
||||
|
||||
Object.keys(themeChange).forEach(key => {
|
||||
const themeChangeFn = themeChange[key]
|
||||
if (['disqus', 'disqusjs'].includes(key)) {
|
||||
setTimeout(() => themeChangeFn(mode), 300)
|
||||
} else {
|
||||
themeChangeFn(mode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Rightside
|
||||
*/
|
||||
const rightSideFn = {
|
||||
readmode: () => { // read mode
|
||||
const $body = document.body
|
||||
const newEle = document.createElement('button')
|
||||
|
||||
const exitReadMode = () => {
|
||||
$body.classList.remove('read-mode')
|
||||
newEle.remove()
|
||||
newEle.removeEventListener('click', exitReadMode)
|
||||
}
|
||||
|
||||
$body.classList.add('read-mode')
|
||||
newEle.type = 'button'
|
||||
newEle.className = 'fas fa-sign-out-alt exit-readmode'
|
||||
newEle.addEventListener('click', exitReadMode)
|
||||
$body.appendChild(newEle)
|
||||
},
|
||||
darkmode: () => { // switch between light and dark mode
|
||||
const willChangeMode = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'
|
||||
if (willChangeMode === 'dark') {
|
||||
btf.activateDarkMode()
|
||||
GLOBAL_CONFIG.Snackbar !== undefined && btf.snackbarShow(GLOBAL_CONFIG.Snackbar.day_to_night)
|
||||
} else {
|
||||
btf.activateLightMode()
|
||||
GLOBAL_CONFIG.Snackbar !== undefined && btf.snackbarShow(GLOBAL_CONFIG.Snackbar.night_to_day)
|
||||
}
|
||||
btf.saveToLocal.set('theme', willChangeMode, 2)
|
||||
handleThemeChange(willChangeMode)
|
||||
},
|
||||
'rightside-config': item => { // Show or hide rightside-hide-btn
|
||||
const hideLayout = item.firstElementChild
|
||||
if (hideLayout.classList.contains('show')) {
|
||||
hideLayout.classList.add('status')
|
||||
setTimeout(() => {
|
||||
hideLayout.classList.remove('status')
|
||||
}, 300)
|
||||
}
|
||||
|
||||
hideLayout.classList.toggle('show')
|
||||
},
|
||||
'go-up': () => { // Back to top
|
||||
btf.scrollToDest(0, 500)
|
||||
},
|
||||
'hide-aside-btn': () => { // Hide aside
|
||||
const $htmlDom = document.documentElement.classList
|
||||
const saveStatus = $htmlDom.contains('hide-aside') ? 'show' : 'hide'
|
||||
btf.saveToLocal.set('aside-status', saveStatus, 2)
|
||||
$htmlDom.toggle('hide-aside')
|
||||
},
|
||||
'mobile-toc-button': (p, item) => { // Show mobile toc
|
||||
const tocEle = document.getElementById('card-toc')
|
||||
tocEle.style.transition = 'transform 0.3s ease-in-out'
|
||||
|
||||
const tocEleHeight = tocEle.clientHeight
|
||||
const btData = item.getBoundingClientRect()
|
||||
|
||||
const tocEleBottom = window.innerHeight - btData.bottom - 30
|
||||
if (tocEleHeight > tocEleBottom) {
|
||||
tocEle.style.transformOrigin = `right ${tocEleHeight - tocEleBottom - btData.height / 2}px`
|
||||
}
|
||||
|
||||
tocEle.classList.toggle('open')
|
||||
tocEle.addEventListener('transitionend', () => {
|
||||
tocEle.style.cssText = ''
|
||||
}, { once: true })
|
||||
},
|
||||
'chat-btn': () => { // Show chat
|
||||
window.chatBtnFn()
|
||||
},
|
||||
translateLink: () => { // switch between traditional and simplified chinese
|
||||
window.translateFn.translatePage()
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('rightside').addEventListener('click', e => {
|
||||
const $target = e.target.closest('[id]')
|
||||
if ($target && rightSideFn[$target.id]) {
|
||||
rightSideFn[$target.id](e.currentTarget, $target)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* menu
|
||||
* 側邊欄sub-menu 展開/收縮
|
||||
*/
|
||||
const clickFnOfSubMenu = () => {
|
||||
const handleClickOfSubMenu = e => {
|
||||
const target = e.target.closest('.site-page.group')
|
||||
if (!target) return
|
||||
target.classList.toggle('hide')
|
||||
}
|
||||
|
||||
const menusItems = document.querySelector('#sidebar-menus .menus_items')
|
||||
menusItems && menusItems.addEventListener('click', handleClickOfSubMenu)
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机端目录点击
|
||||
*/
|
||||
const openMobileMenu = () => {
|
||||
const toggleMenu = document.getElementById('toggle-menu')
|
||||
if (!toggleMenu) return
|
||||
btf.addEventListenerPjax(toggleMenu, 'click', () => { sidebarFn.open() })
|
||||
}
|
||||
|
||||
/**
|
||||
* 複製時加上版權信息
|
||||
*/
|
||||
const addCopyright = () => {
|
||||
const { limitCount, languages } = GLOBAL_CONFIG.copyright
|
||||
|
||||
const handleCopy = e => {
|
||||
e.preventDefault()
|
||||
const copyFont = window.getSelection(0).toString()
|
||||
let textFont = copyFont
|
||||
if (copyFont.length > limitCount) {
|
||||
textFont = `${copyFont}\n\n\n${languages.author}\n${languages.link}${window.location.href}\n${languages.source}\n${languages.info}`
|
||||
}
|
||||
if (e.clipboardData) {
|
||||
return e.clipboardData.setData('text', textFont)
|
||||
} else {
|
||||
return window.clipboardData.setData('text', textFont)
|
||||
}
|
||||
}
|
||||
|
||||
document.body.addEventListener('copy', handleCopy)
|
||||
}
|
||||
|
||||
/**
|
||||
* 網頁運行時間
|
||||
*/
|
||||
const addRuntime = () => {
|
||||
const $runtimeCount = document.getElementById('runtimeshow')
|
||||
if ($runtimeCount) {
|
||||
const publishDate = $runtimeCount.getAttribute('data-publishDate')
|
||||
$runtimeCount.textContent = `${btf.diffDate(publishDate)} ${GLOBAL_CONFIG.runtime}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 最後一次更新時間
|
||||
*/
|
||||
const addLastPushDate = () => {
|
||||
const $lastPushDateItem = document.getElementById('last-push-date')
|
||||
if ($lastPushDateItem) {
|
||||
const lastPushDate = $lastPushDateItem.getAttribute('data-lastPushDate')
|
||||
$lastPushDateItem.textContent = btf.diffDate(lastPushDate, true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* table overflow
|
||||
*/
|
||||
const addTableWrap = () => {
|
||||
const $table = document.querySelectorAll('#article-container table')
|
||||
if (!$table.length) return
|
||||
|
||||
$table.forEach(item => {
|
||||
if (!item.closest('.highlight')) {
|
||||
btf.wrap(item, 'div', { class: 'table-wrap' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* tag-hide
|
||||
*/
|
||||
const clickFnOfTagHide = () => {
|
||||
const hideButtons = document.querySelectorAll('#article-container .hide-button')
|
||||
if (!hideButtons.length) return
|
||||
hideButtons.forEach(item => item.addEventListener('click', e => {
|
||||
const currentTarget = e.currentTarget
|
||||
currentTarget.classList.add('open')
|
||||
addJustifiedGallery(currentTarget.nextElementSibling.querySelectorAll('.gallery-container'))
|
||||
}, { once: true }))
|
||||
}
|
||||
|
||||
const tabsFn = () => {
|
||||
const navTabsElements = document.querySelectorAll('#article-container .tabs')
|
||||
if (!navTabsElements.length) return
|
||||
|
||||
const setActiveClass = (elements, activeIndex) => {
|
||||
elements.forEach((el, index) => {
|
||||
el.classList.toggle('active', index === activeIndex)
|
||||
})
|
||||
}
|
||||
|
||||
const handleNavClick = e => {
|
||||
const target = e.target.closest('button')
|
||||
if (!target || target.classList.contains('active')) return
|
||||
|
||||
const navItems = [...e.currentTarget.children]
|
||||
const tabContents = [...e.currentTarget.nextElementSibling.children]
|
||||
const indexOfButton = navItems.indexOf(target)
|
||||
setActiveClass(navItems, indexOfButton)
|
||||
e.currentTarget.classList.remove('no-default')
|
||||
setActiveClass(tabContents, indexOfButton)
|
||||
addJustifiedGallery(tabContents[indexOfButton].querySelectorAll('.gallery-container'), true)
|
||||
}
|
||||
|
||||
const handleToTopClick = tabElement => e => {
|
||||
if (e.target.closest('button')) {
|
||||
btf.scrollToDest(btf.getEleTop(tabElement), 300)
|
||||
}
|
||||
}
|
||||
|
||||
navTabsElements.forEach(tabElement => {
|
||||
btf.addEventListenerPjax(tabElement.firstElementChild, 'click', handleNavClick)
|
||||
btf.addEventListenerPjax(tabElement.lastElementChild, 'click', handleToTopClick(tabElement))
|
||||
})
|
||||
}
|
||||
|
||||
const toggleCardCategory = () => {
|
||||
const cardCategory = document.querySelector('#aside-cat-list.expandBtn')
|
||||
if (!cardCategory) return
|
||||
|
||||
const handleToggleBtn = e => {
|
||||
const target = e.target
|
||||
if (target.nodeName === 'I') {
|
||||
e.preventDefault()
|
||||
target.parentNode.classList.toggle('expand')
|
||||
}
|
||||
}
|
||||
btf.addEventListenerPjax(cardCategory, 'click', handleToggleBtn, true)
|
||||
}
|
||||
|
||||
const addPostOutdateNotice = () => {
|
||||
const ele = document.getElementById('post-outdate-notice')
|
||||
if (!ele) return
|
||||
|
||||
const { limitDay, messagePrev, messageNext, postUpdate } = JSON.parse(ele.getAttribute('data'))
|
||||
const diffDay = btf.diffDate(postUpdate)
|
||||
if (diffDay >= limitDay) {
|
||||
ele.textContent = `${messagePrev} ${diffDay} ${messageNext}`
|
||||
ele.hidden = false
|
||||
}
|
||||
}
|
||||
|
||||
const lazyloadImg = () => {
|
||||
window.lazyLoadInstance = new LazyLoad({
|
||||
elements_selector: 'img',
|
||||
threshold: 0,
|
||||
data_src: 'lazy-src'
|
||||
})
|
||||
|
||||
btf.addGlobalFn('pjaxComplete', () => {
|
||||
window.lazyLoadInstance.update()
|
||||
}, 'lazyload')
|
||||
}
|
||||
|
||||
const relativeDate = selector => {
|
||||
selector.forEach(item => {
|
||||
item.textContent = btf.diffDate(item.getAttribute('datetime'), true)
|
||||
item.style.display = 'inline'
|
||||
})
|
||||
}
|
||||
|
||||
const justifiedIndexPostUI = () => {
|
||||
const recentPostsElement = document.getElementById('recent-posts')
|
||||
if (!(recentPostsElement && recentPostsElement.classList.contains('masonry'))) return
|
||||
|
||||
const init = () => {
|
||||
const masonryItem = new InfiniteGrid.MasonryInfiniteGrid('.recent-post-items', {
|
||||
gap: { horizontal: 10, vertical: 20 },
|
||||
useTransform: true,
|
||||
useResizeObserver: true
|
||||
})
|
||||
masonryItem.renderItems()
|
||||
btf.addGlobalFn('pjaxCompleteOnce', () => { masonryItem.destroy() }, 'removeJustifiedIndexPostUI')
|
||||
}
|
||||
|
||||
typeof InfiniteGrid === 'function' ? init() : btf.getScript(`${GLOBAL_CONFIG.infinitegrid.js}`).then(init)
|
||||
}
|
||||
|
||||
const unRefreshFn = () => {
|
||||
window.addEventListener('resize', () => {
|
||||
adjustMenu(false)
|
||||
mobileSidebarOpen && btf.isHidden(document.getElementById('toggle-menu')) && sidebarFn.close()
|
||||
})
|
||||
|
||||
const menuMask = document.getElementById('menu-mask')
|
||||
menuMask && menuMask.addEventListener('click', () => { sidebarFn.close() })
|
||||
|
||||
clickFnOfSubMenu()
|
||||
GLOBAL_CONFIG.islazyloadPlugin && lazyloadImg()
|
||||
GLOBAL_CONFIG.copyright !== undefined && addCopyright()
|
||||
|
||||
if (GLOBAL_CONFIG.autoDarkmode) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
|
||||
if (btf.saveToLocal.get('theme') !== undefined) return
|
||||
e.matches ? handleThemeChange('dark') : handleThemeChange('light')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const forPostFn = () => {
|
||||
addHighlightTool()
|
||||
addPhotoFigcaption()
|
||||
addJustifiedGallery(document.querySelectorAll('#article-container .gallery-container'))
|
||||
runLightbox()
|
||||
scrollFnToDo()
|
||||
addTableWrap()
|
||||
clickFnOfTagHide()
|
||||
tabsFn()
|
||||
}
|
||||
|
||||
const refreshFn = () => {
|
||||
initAdjust()
|
||||
justifiedIndexPostUI()
|
||||
|
||||
if (GLOBAL_CONFIG_SITE.pageType === 'post') {
|
||||
addPostOutdateNotice()
|
||||
GLOBAL_CONFIG.relativeDate.post && relativeDate(document.querySelectorAll('#post-meta time'))
|
||||
} else {
|
||||
GLOBAL_CONFIG.relativeDate.homepage && relativeDate(document.querySelectorAll('#recent-posts time'))
|
||||
GLOBAL_CONFIG.runtime && addRuntime()
|
||||
addLastPushDate()
|
||||
toggleCardCategory()
|
||||
}
|
||||
|
||||
GLOBAL_CONFIG_SITE.pageType === 'home' && scrollDownInIndex()
|
||||
scrollFn()
|
||||
|
||||
forPostFn()
|
||||
GLOBAL_CONFIG_SITE.pageType !== 'shuoshuo' && btf.switchComments(document)
|
||||
openMobileMenu()
|
||||
}
|
||||
|
||||
btf.addGlobalFn('pjaxComplete', refreshFn, 'refreshFn')
|
||||
refreshFn()
|
||||
unRefreshFn()
|
||||
|
||||
// 處理 hexo-blog-encrypt 事件
|
||||
window.addEventListener('hexo-blog-decrypt', e => {
|
||||
forPostFn()
|
||||
window.translateFn.translateInitialization()
|
||||
Object.values(window.globalFn.encrypt).forEach(fn => {
|
||||
fn()
|
||||
})
|
||||
})
|
||||
})
|
||||
562
js/search/algolia.js
Normal file
562
js/search/algolia.js
Normal file
@@ -0,0 +1,562 @@
|
||||
window.addEventListener('load', () => {
|
||||
const { algolia } = GLOBAL_CONFIG
|
||||
const { appId, apiKey, indexName, hitsPerPage = 5, languages } = algolia
|
||||
|
||||
if (!appId || !apiKey || !indexName) {
|
||||
return console.error('Algolia setting is invalid!')
|
||||
}
|
||||
|
||||
const $searchMask = document.getElementById('search-mask')
|
||||
const $searchDialog = document.querySelector('#algolia-search .search-dialog')
|
||||
|
||||
const animateElements = show => {
|
||||
const action = show ? 'animateIn' : 'animateOut'
|
||||
const maskAnimation = show ? 'to_show 0.5s' : 'to_hide 0.5s'
|
||||
const dialogAnimation = show ? 'titleScale 0.5s' : 'search_close .5s'
|
||||
btf[action]($searchMask, maskAnimation)
|
||||
btf[action]($searchDialog, dialogAnimation)
|
||||
}
|
||||
|
||||
const fixSafariHeight = () => {
|
||||
if (window.innerWidth < 768) {
|
||||
$searchDialog.style.setProperty('--search-height', `${window.innerHeight}px`)
|
||||
}
|
||||
}
|
||||
|
||||
const openSearch = () => {
|
||||
btf.overflowPaddingR.add()
|
||||
animateElements(true)
|
||||
showLoading(false)
|
||||
|
||||
setTimeout(() => {
|
||||
const searchInput = document.querySelector('#algolia-search-input .ais-SearchBox-input')
|
||||
if (searchInput) searchInput.focus()
|
||||
}, 100)
|
||||
|
||||
const handleEscape = event => {
|
||||
if (event.code === 'Escape') {
|
||||
closeSearch()
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
fixSafariHeight()
|
||||
window.addEventListener('resize', fixSafariHeight)
|
||||
}
|
||||
|
||||
const closeSearch = () => {
|
||||
btf.overflowPaddingR.remove()
|
||||
animateElements(false)
|
||||
window.removeEventListener('resize', fixSafariHeight)
|
||||
}
|
||||
|
||||
const searchClickFn = () => {
|
||||
btf.addEventListenerPjax(document.querySelector('#search-button > .search'), 'click', openSearch)
|
||||
}
|
||||
|
||||
const searchFnOnce = () => {
|
||||
$searchMask.addEventListener('click', closeSearch)
|
||||
document.querySelector('#algolia-search .search-close-button').addEventListener('click', closeSearch)
|
||||
}
|
||||
|
||||
const cutContent = content => {
|
||||
if (!content) return ''
|
||||
|
||||
let contentStr = ''
|
||||
if (typeof content === 'string') {
|
||||
contentStr = content.trim()
|
||||
} else if (typeof content === 'object') {
|
||||
if (content.value !== undefined) {
|
||||
contentStr = String(content.value).trim()
|
||||
if (!contentStr) return ''
|
||||
} else if (content.matchedWords || content.matchLevel || content.fullyHighlighted !== undefined) {
|
||||
return ''
|
||||
} else {
|
||||
try {
|
||||
contentStr = JSON.stringify(content).trim()
|
||||
if (contentStr === '{}' || contentStr === '[]' || contentStr === '""') {
|
||||
return ''
|
||||
}
|
||||
} catch (e) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
} else if (content.toString && typeof content.toString === 'function') {
|
||||
contentStr = content.toString().trim()
|
||||
if (contentStr === '[object Object]' || contentStr === '[object Array]') {
|
||||
return ''
|
||||
}
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
|
||||
const firstOccur = contentStr.indexOf('<mark>')
|
||||
let start = firstOccur - 30
|
||||
let end = firstOccur + 120
|
||||
let pre = ''
|
||||
let post = ''
|
||||
|
||||
if (start <= 0) {
|
||||
start = 0
|
||||
end = 140
|
||||
} else {
|
||||
pre = '...'
|
||||
}
|
||||
|
||||
if (end > contentStr.length) {
|
||||
end = contentStr.length
|
||||
} else {
|
||||
post = '...'
|
||||
}
|
||||
|
||||
// Ensure we don't cut off HTML tags in the middle
|
||||
let substr = contentStr.substring(start, end)
|
||||
|
||||
// Handle tag completeness
|
||||
// Check for incomplete opening tags at the beginning
|
||||
const firstCloseBracket = substr.indexOf('>')
|
||||
const firstOpenBracket = substr.indexOf('<')
|
||||
|
||||
// If there's a closing bracket but no opening bracket before it, we've cut a tag
|
||||
if (firstCloseBracket !== -1 && (firstOpenBracket === -1 || firstCloseBracket < firstOpenBracket)) {
|
||||
substr = substr.substring(firstCloseBracket + 1)
|
||||
}
|
||||
|
||||
// Check for incomplete closing tags at the end
|
||||
const lastOpenBracket = substr.lastIndexOf('<')
|
||||
const lastCloseBracket = substr.lastIndexOf('>')
|
||||
|
||||
// If there's an opening bracket after the last closing bracket, we've cut a tag
|
||||
if (lastOpenBracket !== -1 && lastOpenBracket > lastCloseBracket) {
|
||||
substr = substr.substring(0, lastOpenBracket)
|
||||
}
|
||||
|
||||
// Balance tags in the substring
|
||||
const tagStack = []
|
||||
let balancedStr = ''
|
||||
let i = 0
|
||||
|
||||
while (i < substr.length) {
|
||||
if (substr[i] === '<') {
|
||||
// Check if it's a closing tag
|
||||
if (substr[i + 1] === '/') {
|
||||
const closeTagEnd = substr.indexOf('>', i)
|
||||
if (closeTagEnd !== -1) {
|
||||
const closeTagName = substr.substring(i + 2, closeTagEnd)
|
||||
// Remove matching opening tag from stack
|
||||
for (let j = tagStack.length - 1; j >= 0; j--) {
|
||||
if (tagStack[j] === closeTagName) {
|
||||
tagStack.splice(j, 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
balancedStr += substr.substring(i, closeTagEnd + 1)
|
||||
i = closeTagEnd + 1
|
||||
continue
|
||||
}
|
||||
} else if (substr.substr(i, 2) === '<!' || (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}`
|
||||
}
|
||||
|
||||
// Helper function to handle Algolia highlight results
|
||||
const extractHighlightValue = highlightObj => {
|
||||
if (!highlightObj) return ''
|
||||
|
||||
if (typeof highlightObj === 'string') {
|
||||
return highlightObj.trim()
|
||||
}
|
||||
|
||||
if (typeof highlightObj === 'object' && highlightObj.value !== undefined) {
|
||||
return String(highlightObj.value).trim()
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
// Initialize Algolia client
|
||||
let searchClient
|
||||
|
||||
if (window['algoliasearch/lite'] && typeof window['algoliasearch/lite'].liteClient === 'function') {
|
||||
searchClient = window['algoliasearch/lite'].liteClient(appId, apiKey)
|
||||
} else if (typeof window.algoliasearch === 'function') {
|
||||
searchClient = window.algoliasearch(appId, apiKey)
|
||||
} else {
|
||||
return console.error('Algolia search client not found!')
|
||||
}
|
||||
|
||||
if (!searchClient) {
|
||||
return console.error('Failed to initialize Algolia search client')
|
||||
}
|
||||
|
||||
// Search state
|
||||
let currentQuery = ''
|
||||
|
||||
// Show loading state
|
||||
const showLoading = show => {
|
||||
const loadingIndicator = document.getElementById('loading-status')
|
||||
if (loadingIndicator) {
|
||||
loadingIndicator.hidden = !show
|
||||
}
|
||||
}
|
||||
|
||||
// Cache frequently used elements
|
||||
const elements = {
|
||||
get searchInput () { return document.querySelector('#algolia-search-input .ais-SearchBox-input') },
|
||||
get hits () { return document.getElementById('algolia-hits') },
|
||||
get hitsEmpty () { return document.getElementById('algolia-hits-empty') },
|
||||
get hitsList () { return document.querySelector('#algolia-hits .ais-Hits-list') },
|
||||
get hitsWrapper () { return document.querySelector('#algolia-hits .ais-Hits') },
|
||||
get pagination () { return document.getElementById('algolia-pagination') },
|
||||
get paginationList () { return document.querySelector('#algolia-pagination .ais-Pagination-list') },
|
||||
get stats () { return document.querySelector('#algolia-info .ais-Stats-text') },
|
||||
}
|
||||
|
||||
// Show/hide search results area
|
||||
const toggleResultsVisibility = hasResults => {
|
||||
elements.pagination.style.display = hasResults ? '' : 'none'
|
||||
elements.stats.style.display = hasResults ? '' : 'none'
|
||||
}
|
||||
|
||||
// Render search results
|
||||
const renderHits = (hits, query, page = 0) => {
|
||||
if (hits.length === 0 && query) {
|
||||
elements.hitsEmpty.textContent = languages.hits_empty.replace(/\$\{query}/, query)
|
||||
elements.hitsEmpty.style.display = ''
|
||||
elements.hitsWrapper.style.display = 'none'
|
||||
elements.stats.style.display = 'none'
|
||||
return
|
||||
}
|
||||
|
||||
elements.hitsEmpty.style.display = 'none'
|
||||
|
||||
const hitsHTML = hits.map((hit, index) => {
|
||||
const itemNumber = page * hitsPerPage + index + 1
|
||||
const link = hit.permalink || (GLOBAL_CONFIG.root + hit.path)
|
||||
const result = hit._highlightResult || hit
|
||||
|
||||
// Content extraction
|
||||
let content = ''
|
||||
try {
|
||||
if (result.contentStripTruncate) {
|
||||
content = cutContent(result.contentStripTruncate)
|
||||
} else if (result.contentStrip) {
|
||||
content = cutContent(result.contentStrip)
|
||||
} else if (result.content) {
|
||||
content = cutContent(result.content)
|
||||
} else if (hit.contentStripTruncate) {
|
||||
content = cutContent(hit.contentStripTruncate)
|
||||
} else if (hit.contentStrip) {
|
||||
content = cutContent(hit.contentStrip)
|
||||
} else if (hit.content) {
|
||||
content = cutContent(hit.content)
|
||||
}
|
||||
} catch (error) {
|
||||
content = ''
|
||||
}
|
||||
|
||||
// Title handling
|
||||
let title = 'no-title'
|
||||
try {
|
||||
if (result.title) {
|
||||
title = extractHighlightValue(result.title) || 'no-title'
|
||||
} else if (hit.title) {
|
||||
title = extractHighlightValue(hit.title) || 'no-title'
|
||||
}
|
||||
|
||||
if (!title || title === 'no-title') {
|
||||
if (typeof hit.title === 'string' && hit.title.trim()) {
|
||||
title = hit.title.trim()
|
||||
} else if (hit.title && typeof hit.title === 'object' && hit.title.value) {
|
||||
title = String(hit.title.value).trim() || 'no-title'
|
||||
} else {
|
||||
title = 'no-title'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
title = 'no-title'
|
||||
}
|
||||
|
||||
return `
|
||||
<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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Initial state
|
||||
toggleResultsVisibility(false)
|
||||
}
|
||||
|
||||
// Initialize
|
||||
initializeSearch()
|
||||
searchClickFn()
|
||||
searchFnOnce()
|
||||
|
||||
window.addEventListener('pjax:complete', () => {
|
||||
if (!btf.isHidden($searchMask)) closeSearch()
|
||||
searchClickFn()
|
||||
})
|
||||
})
|
||||
567
js/search/local-search.js
Normal file
567
js/search/local-search.js
Normal file
@@ -0,0 +1,567 @@
|
||||
/**
|
||||
* Refer to hexo-generator-searchdb
|
||||
* https://github.com/next-theme/hexo-generator-searchdb/blob/main/dist/search.js
|
||||
* Modified by hexo-theme-butterfly
|
||||
*/
|
||||
|
||||
class LocalSearch {
|
||||
constructor ({
|
||||
path = '',
|
||||
unescape = false,
|
||||
top_n_per_article = 1
|
||||
}) {
|
||||
this.path = path
|
||||
this.unescape = unescape
|
||||
this.top_n_per_article = top_n_per_article
|
||||
this.isfetched = false
|
||||
this.datas = null
|
||||
}
|
||||
|
||||
getIndexByWord (words, text, caseSensitive = false) {
|
||||
const index = []
|
||||
const included = new Set()
|
||||
|
||||
if (!caseSensitive) {
|
||||
text = text.toLowerCase()
|
||||
}
|
||||
words.forEach(word => {
|
||||
if (this.unescape) {
|
||||
const div = document.createElement('div')
|
||||
div.innerText = word
|
||||
word = div.innerHTML
|
||||
}
|
||||
const wordLen = word.length
|
||||
if (wordLen === 0) return
|
||||
let startPosition = 0
|
||||
let position = -1
|
||||
if (!caseSensitive) {
|
||||
word = word.toLowerCase()
|
||||
}
|
||||
while ((position = text.indexOf(word, startPosition)) > -1) {
|
||||
index.push({ position, word })
|
||||
included.add(word)
|
||||
startPosition = position + wordLen
|
||||
}
|
||||
})
|
||||
// Sort index by position of keyword
|
||||
index.sort((left, right) => {
|
||||
if (left.position !== right.position) {
|
||||
return left.position - right.position
|
||||
}
|
||||
return right.word.length - left.word.length
|
||||
})
|
||||
return [index, included]
|
||||
}
|
||||
|
||||
// Merge hits into slices
|
||||
mergeIntoSlice (start, end, index) {
|
||||
let item = index[0]
|
||||
let { position, word } = item
|
||||
const hits = []
|
||||
const count = new Set()
|
||||
while (position + word.length <= end && index.length !== 0) {
|
||||
count.add(word)
|
||||
hits.push({
|
||||
position,
|
||||
length: word.length
|
||||
})
|
||||
const wordEnd = position + word.length
|
||||
|
||||
// Move to next position of hit
|
||||
index.shift()
|
||||
while (index.length !== 0) {
|
||||
item = index[0]
|
||||
position = item.position
|
||||
word = item.word
|
||||
if (wordEnd > position) {
|
||||
index.shift()
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
hits,
|
||||
start,
|
||||
end,
|
||||
count: count.size
|
||||
}
|
||||
}
|
||||
|
||||
// Highlight title and content
|
||||
highlightKeyword (val, slice) {
|
||||
let result = ''
|
||||
let index = slice.start
|
||||
for (const { position, length } of slice.hits) {
|
||||
result += val.substring(index, position)
|
||||
index = position + length
|
||||
result += `<mark class="search-keyword">${val.substr(position, length)}</mark>`
|
||||
}
|
||||
result += val.substring(index, slice.end)
|
||||
return result
|
||||
}
|
||||
|
||||
getResultItems (keywords) {
|
||||
const resultItems = []
|
||||
this.datas.forEach(({ title, content, url }) => {
|
||||
// The number of different keywords included in the article.
|
||||
const [indexOfTitle, keysOfTitle] = this.getIndexByWord(keywords, title)
|
||||
const [indexOfContent, keysOfContent] = this.getIndexByWord(keywords, content)
|
||||
const includedCount = new Set([...keysOfTitle, ...keysOfContent]).size
|
||||
|
||||
// Show search results
|
||||
const hitCount = indexOfTitle.length + indexOfContent.length
|
||||
if (hitCount === 0) return
|
||||
|
||||
const slicesOfTitle = []
|
||||
if (indexOfTitle.length !== 0) {
|
||||
slicesOfTitle.push(this.mergeIntoSlice(0, title.length, indexOfTitle))
|
||||
}
|
||||
|
||||
let slicesOfContent = []
|
||||
while (indexOfContent.length !== 0) {
|
||||
const item = indexOfContent[0]
|
||||
const { position } = item
|
||||
// Cut out 120 characters. The maxlength of .search-input is 80.
|
||||
const start = Math.max(0, position - 20)
|
||||
const end = Math.min(content.length, position + 100)
|
||||
slicesOfContent.push(this.mergeIntoSlice(start, end, indexOfContent))
|
||||
}
|
||||
|
||||
// Sort slices in content by included keywords' count and hits' count
|
||||
slicesOfContent.sort((left, right) => {
|
||||
if (left.count !== right.count) {
|
||||
return right.count - left.count
|
||||
} else if (left.hits.length !== right.hits.length) {
|
||||
return right.hits.length - left.hits.length
|
||||
}
|
||||
return left.start - right.start
|
||||
})
|
||||
|
||||
// Select top N slices in content
|
||||
const upperBound = parseInt(this.top_n_per_article, 10)
|
||||
if (upperBound >= 0) {
|
||||
slicesOfContent = slicesOfContent.slice(0, upperBound)
|
||||
}
|
||||
|
||||
let resultItem = ''
|
||||
|
||||
url = new URL(url, location.origin)
|
||||
url.searchParams.append('highlight', keywords.join(' '))
|
||||
|
||||
if (slicesOfTitle.length !== 0) {
|
||||
resultItem += `<li class="local-search-hit-item"><a href="${url.href}"><span class="search-result-title">${this.highlightKeyword(title, slicesOfTitle[0])}</span>`
|
||||
} else {
|
||||
resultItem += `<li class="local-search-hit-item"><a href="${url.href}"><span class="search-result-title">${title}</span>`
|
||||
}
|
||||
|
||||
slicesOfContent.forEach(slice => {
|
||||
resultItem += `<p class="search-result">${this.highlightKeyword(content, slice)}...</p></a>`
|
||||
})
|
||||
|
||||
resultItem += '</li>'
|
||||
resultItems.push({
|
||||
item: resultItem,
|
||||
id: resultItems.length,
|
||||
hitCount,
|
||||
includedCount
|
||||
})
|
||||
})
|
||||
return resultItems
|
||||
}
|
||||
|
||||
fetchData () {
|
||||
const isXml = !this.path.endsWith('json')
|
||||
fetch(this.path)
|
||||
.then(response => response.text())
|
||||
.then(res => {
|
||||
// Get the contents from search data
|
||||
this.isfetched = true
|
||||
this.datas = isXml
|
||||
? [...new DOMParser().parseFromString(res, 'text/xml').querySelectorAll('entry')].map(element => ({
|
||||
title: element.querySelector('title').textContent,
|
||||
content: element.querySelector('content').textContent,
|
||||
url: element.querySelector('url').textContent
|
||||
}))
|
||||
: JSON.parse(res)
|
||||
// Only match articles with non-empty titles
|
||||
this.datas = this.datas.filter(data => data.title).map(data => {
|
||||
data.title = data.title.trim()
|
||||
data.content = data.content ? data.content.trim().replace(/<[^>]+>/g, '') : ''
|
||||
data.url = decodeURIComponent(data.url).replace(/\/{2,}/g, '/')
|
||||
return data
|
||||
})
|
||||
// Remove loading animation
|
||||
window.dispatchEvent(new Event('search:loaded'))
|
||||
})
|
||||
}
|
||||
|
||||
// Highlight by wrapping node in mark elements with the given class name
|
||||
highlightText (node, slice, className) {
|
||||
const val = node.nodeValue
|
||||
let index = slice.start
|
||||
const children = []
|
||||
for (const { position, length } of slice.hits) {
|
||||
const text = document.createTextNode(val.substring(index, position))
|
||||
index = position + length
|
||||
const mark = document.createElement('mark')
|
||||
mark.className = className
|
||||
mark.appendChild(document.createTextNode(val.substr(position, length)))
|
||||
children.push(text, mark)
|
||||
}
|
||||
node.nodeValue = val.substring(index, slice.end)
|
||||
children.forEach(element => {
|
||||
node.parentNode.insertBefore(element, node)
|
||||
})
|
||||
}
|
||||
|
||||
// Highlight the search words provided in the url in the text
|
||||
highlightSearchWords (body) {
|
||||
const params = new URL(location.href).searchParams.get('highlight')
|
||||
const keywords = params ? params.split(' ') : []
|
||||
if (!keywords.length || !body) return
|
||||
const walk = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, null)
|
||||
const allNodes = []
|
||||
while (walk.nextNode()) {
|
||||
if (!walk.currentNode.parentNode.matches('button, select, textarea, .mermaid')) allNodes.push(walk.currentNode)
|
||||
}
|
||||
allNodes.forEach(node => {
|
||||
const [indexOfNode] = this.getIndexByWord(keywords, node.nodeValue)
|
||||
if (!indexOfNode.length) return
|
||||
const slice = this.mergeIntoSlice(0, node.nodeValue.length, indexOfNode)
|
||||
this.highlightText(node, slice, 'search-keyword')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
// Search
|
||||
const { path, top_n_per_article, unescape, languages, pagination } = GLOBAL_CONFIG.localSearch
|
||||
const enablePagination = pagination && pagination.enable
|
||||
const localSearch = new LocalSearch({
|
||||
path,
|
||||
top_n_per_article,
|
||||
unescape
|
||||
})
|
||||
|
||||
const input = document.querySelector('.local-search-input input')
|
||||
const statsItem = document.getElementById('local-search-stats')
|
||||
const $loadingStatus = document.getElementById('loading-status')
|
||||
const isXml = !path.endsWith('json')
|
||||
|
||||
// Pagination variables (only initialize if pagination is enabled)
|
||||
let currentPage = 0
|
||||
const hitsPerPage = pagination.hitsPerPage || 10
|
||||
|
||||
let currentResultItems = []
|
||||
|
||||
if (!enablePagination) {
|
||||
// If pagination is disabled, we don't need these variables
|
||||
currentPage = undefined
|
||||
currentResultItems = undefined
|
||||
}
|
||||
|
||||
// Cache frequently used elements
|
||||
const elements = {
|
||||
get pagination () { return document.getElementById('local-search-pagination') },
|
||||
get paginationList () { return document.querySelector('#local-search-pagination .ais-Pagination-list') }
|
||||
}
|
||||
|
||||
// Show/hide search results area
|
||||
const toggleResultsVisibility = hasResults => {
|
||||
if (enablePagination) {
|
||||
elements.pagination.style.display = hasResults ? '' : 'none'
|
||||
} else {
|
||||
elements.pagination.style.display = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
// Render search results for current page
|
||||
const renderResults = (searchText, resultItems) => {
|
||||
const container = document.getElementById('local-search-results')
|
||||
|
||||
// Determine items to display based on pagination mode
|
||||
const itemsToDisplay = enablePagination
|
||||
? currentResultItems.slice(currentPage * hitsPerPage, (currentPage + 1) * hitsPerPage)
|
||||
: resultItems
|
||||
|
||||
// Handle empty page in pagination mode
|
||||
if (enablePagination && itemsToDisplay.length === 0 && currentResultItems.length > 0) {
|
||||
currentPage = 0
|
||||
renderResults(searchText, resultItems)
|
||||
return
|
||||
}
|
||||
|
||||
// Add numbering to items
|
||||
const numberedItems = itemsToDisplay.map((result, index) => {
|
||||
const itemNumber = enablePagination
|
||||
? currentPage * hitsPerPage + index + 1
|
||||
: index + 1
|
||||
return result.item.replace(
|
||||
'<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, '<').replace(/>/g, '>'))
|
||||
|
||||
if (searchText !== '') $loadingStatus.hidden = false
|
||||
|
||||
const keywords = searchText.split(/[-\s]+/)
|
||||
let resultItems = []
|
||||
|
||||
if (searchText.length > 0) {
|
||||
resultItems = localSearch.getResultItems(keywords)
|
||||
}
|
||||
|
||||
if (keywords.length === 1 && keywords[0] === '') {
|
||||
clearSearchResults()
|
||||
} else if (resultItems.length === 0) {
|
||||
showNoResults(searchText)
|
||||
} else {
|
||||
// Sort results by relevance
|
||||
resultItems.sort((left, right) => {
|
||||
if (left.includedCount !== right.includedCount) {
|
||||
return right.includedCount - left.includedCount
|
||||
} else if (left.hitCount !== right.hitCount) {
|
||||
return right.hitCount - left.hitCount
|
||||
}
|
||||
return right.id - left.id
|
||||
})
|
||||
|
||||
if (enablePagination) {
|
||||
currentResultItems = resultItems
|
||||
currentPage = 0
|
||||
}
|
||||
renderResults(searchText, resultItems)
|
||||
}
|
||||
|
||||
$loadingStatus.hidden = true
|
||||
}
|
||||
|
||||
let loadFlag = false
|
||||
const $searchMask = document.getElementById('search-mask')
|
||||
const $searchDialog = document.querySelector('#local-search .search-dialog')
|
||||
|
||||
// fix safari
|
||||
const fixSafariHeight = () => {
|
||||
if (window.innerWidth < 768) {
|
||||
$searchDialog.style.setProperty('--search-height', window.innerHeight + 'px')
|
||||
}
|
||||
}
|
||||
|
||||
const openSearch = () => {
|
||||
btf.overflowPaddingR.add()
|
||||
btf.animateIn($searchMask, 'to_show 0.5s')
|
||||
btf.animateIn($searchDialog, 'titleScale 0.5s')
|
||||
setTimeout(() => { input.focus() }, 300)
|
||||
if (!loadFlag) {
|
||||
!localSearch.isfetched && localSearch.fetchData()
|
||||
input.addEventListener('input', inputEventFunction)
|
||||
loadFlag = true
|
||||
}
|
||||
// shortcut: ESC
|
||||
document.addEventListener('keydown', function f (event) {
|
||||
if (event.code === 'Escape') {
|
||||
closeSearch()
|
||||
document.removeEventListener('keydown', f)
|
||||
}
|
||||
})
|
||||
|
||||
fixSafariHeight()
|
||||
window.addEventListener('resize', fixSafariHeight)
|
||||
}
|
||||
|
||||
const closeSearch = () => {
|
||||
btf.overflowPaddingR.remove()
|
||||
btf.animateOut($searchDialog, 'search_close .5s')
|
||||
btf.animateOut($searchMask, 'to_hide 0.5s')
|
||||
window.removeEventListener('resize', fixSafariHeight)
|
||||
}
|
||||
|
||||
const searchClickFn = () => {
|
||||
btf.addEventListenerPjax(document.querySelector('#search-button > .search'), 'click', openSearch)
|
||||
}
|
||||
|
||||
const searchFnOnce = () => {
|
||||
document.querySelector('#local-search .search-close-button').addEventListener('click', closeSearch)
|
||||
$searchMask.addEventListener('click', closeSearch)
|
||||
if (GLOBAL_CONFIG.localSearch.preload) {
|
||||
localSearch.fetchData()
|
||||
}
|
||||
localSearch.highlightSearchWords(document.getElementById('article-container'))
|
||||
|
||||
// Pagination event delegation - only add if pagination is enabled
|
||||
if (enablePagination) {
|
||||
elements.pagination.addEventListener('click', e => {
|
||||
e.preventDefault()
|
||||
const link = e.target.closest('a[data-page]')
|
||||
if (link) {
|
||||
const page = parseInt(link.dataset.page, 10)
|
||||
if (!isNaN(page) && currentResultItems.length > 0) {
|
||||
currentPage = page
|
||||
renderResults(input.value.trim().toLowerCase(), currentResultItems)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Initial state
|
||||
toggleResultsVisibility(false)
|
||||
}
|
||||
|
||||
window.addEventListener('search:loaded', () => {
|
||||
const $loadDataItem = document.getElementById('loading-database')
|
||||
$loadDataItem.nextElementSibling.style.visibility = 'visible'
|
||||
$loadDataItem.remove()
|
||||
})
|
||||
|
||||
searchClickFn()
|
||||
searchFnOnce()
|
||||
|
||||
// pjax
|
||||
window.addEventListener('pjax:complete', () => {
|
||||
!btf.isHidden($searchMask) && closeSearch()
|
||||
localSearch.highlightSearchWords(document.getElementById('article-container'))
|
||||
searchClickFn()
|
||||
})
|
||||
})
|
||||
77
js/tools/base64-img.js
Normal file
77
js/tools/base64-img.js
Normal file
@@ -0,0 +1,77 @@
|
||||
(function() {
|
||||
function initBase64Tool() {
|
||||
const container = document.getElementById('tool-base64-container');
|
||||
if (!container) return;
|
||||
|
||||
container.addEventListener('click', (e) => e.stopPropagation());
|
||||
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const fileInput = document.getElementById('file-input');
|
||||
const outputBase64 = document.getElementById('output-base64');
|
||||
const btnCopy = document.getElementById('btn-copy');
|
||||
const btnClear = document.getElementById('btn-clear');
|
||||
const inputBase64 = document.getElementById('input-base64');
|
||||
const btnPreview = document.getElementById('btn-preview');
|
||||
const imagePreview = document.getElementById('image-preview');
|
||||
|
||||
// Image to Base64
|
||||
function processFile(file) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('请上传图片文件');
|
||||
return;
|
||||
}
|
||||
outputBase64.value = '处理中...';
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
outputBase64.value = e.target.result;
|
||||
};
|
||||
reader.onerror = () => {
|
||||
alert('读取文件失败');
|
||||
outputBase64.value = '';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
dropZone.addEventListener('click', () => fileInput.click());
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.style.background = '#eef4fe';
|
||||
});
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.style.background = '';
|
||||
});
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.style.background = '';
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length) processFile(files[0]);
|
||||
});
|
||||
fileInput.addEventListener('change', () => {
|
||||
if (fileInput.files.length) processFile(fileInput.files[0]);
|
||||
});
|
||||
|
||||
btnCopy.addEventListener('click', () => {
|
||||
if (!outputBase64.value) return;
|
||||
outputBase64.select();
|
||||
navigator.clipboard.writeText(outputBase64.value).then(() => alert('已复制到剪贴板'));
|
||||
});
|
||||
|
||||
btnClear.addEventListener('click', () => {
|
||||
outputBase64.value = '';
|
||||
fileInput.value = '';
|
||||
});
|
||||
|
||||
// Base64 to Image
|
||||
btnPreview.addEventListener('click', () => {
|
||||
const val = inputBase64.value.trim();
|
||||
if (!val) {
|
||||
imagePreview.innerHTML = '<span style="color: #999;">预览区域</span>';
|
||||
return;
|
||||
}
|
||||
imagePreview.innerHTML = `<img src="${val}" style="max-width:100%; box-shadow:0 2px 8px rgba(0,0,0,0.2);" onerror="this.parentElement.innerHTML='<span style=\\'color:red\\'>无法解析图片,请检查 Base64 代码是否完整</span>'">`;
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initBase64Tool);
|
||||
document.addEventListener('pjax:complete', initBase64Tool);
|
||||
})();
|
||||
605
js/tools/converter.js
Normal file
605
js/tools/converter.js
Normal file
@@ -0,0 +1,605 @@
|
||||
(function () {
|
||||
const DEFAULT_BASE_URL = 'https://doc.luozili.work';
|
||||
const DEFAULT_API_KEY = '';
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
const MAX_POLL_ROUNDS = 120;
|
||||
const MAX_CONCURRENCY = 2;
|
||||
const DEBUG_PREFIX = '[ConvertX-Debug]';
|
||||
const DEBUG_LOG_LIMIT = 200;
|
||||
const ASSET_STORAGE_KEY = 'convertx_asset_center_v1';
|
||||
|
||||
const FALLBACK_CONVERTER_MAP = {
|
||||
jpg: 'imagemagick', png: 'imagemagick', webp: 'imagemagick',
|
||||
pdf: 'libreoffice', docx: 'libreoffice', csv: 'dasel',
|
||||
xlsx: 'libreoffice', md: 'markitDown', txt: 'pandoc',
|
||||
gif: 'ffmpeg', mp4: 'ffmpeg', mp3: 'ffmpeg'
|
||||
};
|
||||
|
||||
const Bus = {
|
||||
listeners: {},
|
||||
on(event, handler) {
|
||||
if (!this.listeners[event]) this.listeners[event] = [];
|
||||
this.listeners[event].push(handler);
|
||||
},
|
||||
emit(event, payload) {
|
||||
(this.listeners[event] || []).forEach((fn) => {
|
||||
try { fn(payload); } catch (e) { console.warn(e); }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const Store = {
|
||||
state: {
|
||||
files: [],
|
||||
targetFormat: null,
|
||||
tasks: {},
|
||||
running: 0,
|
||||
converterCapabilities: {}
|
||||
},
|
||||
setFiles(files) {
|
||||
this.state.files = files;
|
||||
Bus.emit('files:changed', files);
|
||||
},
|
||||
setTargetFormat(fmt) {
|
||||
this.state.targetFormat = fmt;
|
||||
Bus.emit('target:changed', fmt);
|
||||
},
|
||||
upsertTask(task) {
|
||||
this.state.tasks[task.id] = { ...(this.state.tasks[task.id] || {}), ...task };
|
||||
Bus.emit('task:updated', this.state.tasks[task.id]);
|
||||
},
|
||||
getTask(id) {
|
||||
return this.state.tasks[id] || null;
|
||||
},
|
||||
getTasks() {
|
||||
return Object.values(this.state.tasks).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
||||
},
|
||||
clearTasks() {
|
||||
this.state.tasks = {};
|
||||
Bus.emit('queue:reset');
|
||||
}
|
||||
};
|
||||
|
||||
const Api = {
|
||||
getBaseUrl() {
|
||||
const val = (document.getElementById('convertx-base-url')?.value || '').trim();
|
||||
return (val || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
||||
},
|
||||
getApiKey() {
|
||||
const val = (document.getElementById('api-key')?.value || '').trim();
|
||||
return val || DEFAULT_API_KEY;
|
||||
},
|
||||
getAuthMode(apiKey) {
|
||||
return apiKey ? 'X-API-Key + Cookie' : 'Cookie';
|
||||
},
|
||||
async request(path, options = {}) {
|
||||
const baseUrl = this.getBaseUrl();
|
||||
const apiKey = this.getApiKey();
|
||||
const url = `${baseUrl}${path}`;
|
||||
const pageOrigin = window.location.origin;
|
||||
let credentialsMode = 'include';
|
||||
let sameOrigin = true;
|
||||
try {
|
||||
sameOrigin = new URL(url, window.location.href).origin === window.location.origin;
|
||||
credentialsMode = sameOrigin ? 'include' : 'omit';
|
||||
} catch (_) {
|
||||
credentialsMode = 'include';
|
||||
sameOrigin = true;
|
||||
}
|
||||
const traceId = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
const headers = { ...(options.headers || {}) };
|
||||
const useApiKey = options.useApiKey !== false;
|
||||
if (useApiKey && apiKey) headers['X-API-Key'] = apiKey;
|
||||
const debugMeta = options.debugMeta || null;
|
||||
|
||||
const parseResponse = async (res, traceIdForLog, requestUrl) => {
|
||||
const text = await res.text();
|
||||
debugLog('api.response', { traceId: traceIdForLog, status: res.status, url: requestUrl, body_preview: (text || '').slice(0, 300) });
|
||||
let data = null;
|
||||
try { data = text ? JSON.parse(text) : null; } catch (_) { data = null; }
|
||||
return { ok: res.ok, status: res.status, text, data, headers: res.headers, traceId: traceIdForLog };
|
||||
};
|
||||
|
||||
debugLog('api.request', {
|
||||
traceId,
|
||||
url,
|
||||
method: options.method || 'GET',
|
||||
auth: this.getAuthMode(useApiKey ? apiKey : ''),
|
||||
page_origin: pageOrigin,
|
||||
online: navigator.onLine,
|
||||
has_custom_headers: Object.keys(headers).length > 0,
|
||||
use_api_key: useApiKey,
|
||||
credentials_mode: credentialsMode,
|
||||
same_origin: sameOrigin,
|
||||
debug_meta: debugMeta
|
||||
});
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, { ...options, headers, credentials: credentialsMode });
|
||||
} catch (err) {
|
||||
debugLog('api.network_error', {
|
||||
traceId,
|
||||
url,
|
||||
page_origin: pageOrigin,
|
||||
error_name: err?.name || 'UnknownError',
|
||||
error_message: err?.message || 'Failed to fetch',
|
||||
online: navigator.onLine,
|
||||
protocol_mode: (document.getElementById('protocol-mode')?.value || 'auto'),
|
||||
credentials_mode: credentialsMode,
|
||||
same_origin: sameOrigin,
|
||||
debug_meta: debugMeta,
|
||||
curl_probe: {
|
||||
options: `curl -i -X OPTIONS "${url}" -H "Origin: ${pageOrigin}" -H "Access-Control-Request-Method: ${options.method || 'POST'}" -H "Access-Control-Request-Headers: x-api-key,content-type"`,
|
||||
post_no_key: `curl -i -X ${(options.method || 'POST').toUpperCase()} "${url}" -H "Origin: ${pageOrigin}"`,
|
||||
post_with_key: `curl -i -X ${(options.method || 'POST').toUpperCase()} "${url}" -H "Origin: ${pageOrigin}" -H "X-API-Key: <api_key>"`
|
||||
}
|
||||
});
|
||||
if (url.includes('/convert')) {
|
||||
throw new Error('网络层失败:浏览器未拿到可用的跨域响应(非业务 4xx/5xx)。请检查 POST /convert 是否返回 Access-Control-Allow-Origin,以及 OPTIONS/POST 的 CORS 头是否一致,并确认放行 X-API-Key');
|
||||
}
|
||||
throw new Error('网络层失败(可能是 CSP connect-src / 跨域预检 / 浏览器安全策略),请检查浏览器 Network 与响应头');
|
||||
}
|
||||
|
||||
return await parseResponse(res, traceId, url);
|
||||
}
|
||||
};
|
||||
|
||||
function createTaskFromFile(file) {
|
||||
const id = Date.now() + Math.floor(Math.random() * 100000);
|
||||
const manualConverter = (document.getElementById('converter-name')?.value || '').trim();
|
||||
const converter = manualConverter || getRecommendedConverter(Store.state.targetFormat) || FALLBACK_CONVERTER_MAP[Store.state.targetFormat] || '';
|
||||
return {
|
||||
id,
|
||||
createdAt: Date.now(),
|
||||
file,
|
||||
filename: file.name,
|
||||
targetFormat: Store.state.targetFormat,
|
||||
converter,
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
message: '等待中...'
|
||||
};
|
||||
}
|
||||
|
||||
async function runScheduler() {
|
||||
const pending = Store.getTasks().filter((t) => t.status === 'pending');
|
||||
if (!pending.length) return;
|
||||
|
||||
while (Store.state.running < MAX_CONCURRENCY && pending.length) {
|
||||
const task = pending.shift();
|
||||
Store.state.running += 1;
|
||||
processTask(task.id).finally(() => {
|
||||
Store.state.running -= 1;
|
||||
runScheduler();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function processTask(taskId) {
|
||||
const task = Store.getTask(taskId);
|
||||
if (!task) return;
|
||||
|
||||
Store.upsertTask({ id: taskId, status: 'running', message: '准备提交任务...', attempts: (task.attempts || 0) + 1 });
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', task.file);
|
||||
formData.append('target_format', task.targetFormat);
|
||||
if (task.converter) formData.append('converter', task.converter);
|
||||
|
||||
debugLog('task.submit.meta', {
|
||||
taskId,
|
||||
filename: task.filename,
|
||||
file_size: task.file?.size || 0,
|
||||
file_size_bucket: (function () {
|
||||
const size = task.file?.size || 0;
|
||||
if (size < 100 * 1024) return '<100KB';
|
||||
if (size < 1024 * 1024) return '100KB-1MB';
|
||||
if (size < 10 * 1024 * 1024) return '1MB-10MB';
|
||||
return '>=10MB';
|
||||
})(),
|
||||
target_format: task.targetFormat,
|
||||
converter: task.converter || null,
|
||||
protocol_mode: (document.getElementById('protocol-mode')?.value || 'auto')
|
||||
});
|
||||
|
||||
let submit;
|
||||
try {
|
||||
submit = await Api.request('/convert', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
useApiKey: true,
|
||||
debugMeta: { filename: task.filename, file_size: task.file?.size || 0, target_format: task.targetFormat }
|
||||
});
|
||||
} catch (firstErr) {
|
||||
const msg = String(firstErr?.message || firstErr || '');
|
||||
const looksLikeCors = /Failed to fetch|CORS|预检|跨域|X-API-Key/i.test(msg);
|
||||
if (!looksLikeCors) throw firstErr;
|
||||
|
||||
debugLog('task.submit.fallback_without_api_key', {
|
||||
taskId,
|
||||
reason: msg,
|
||||
hint: '首次带 X-API-Key 网络失败,降级为不带自定义头重试一次'
|
||||
});
|
||||
submit = await Api.request('/convert', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
useApiKey: false,
|
||||
debugMeta: { filename: task.filename, file_size: task.file?.size || 0, target_format: task.targetFormat, retry_without_api_key: true }
|
||||
});
|
||||
}
|
||||
if (!submit.ok) throw new Error(`提交失败(${submit.status}): ${(submit.text || '').slice(0, 200)}`);
|
||||
|
||||
const protocolMode = (document.getElementById('protocol-mode')?.value || 'auto').toLowerCase();
|
||||
|
||||
const isSyncPayload = Boolean(submit.data && (submit.data.status === 'completed' || Array.isArray(submit.data.files)));
|
||||
if (protocolMode !== 'async' && isSyncPayload) {
|
||||
persistAsset(task, submit.data, null);
|
||||
Store.upsertTask({
|
||||
id: taskId,
|
||||
status: 'done',
|
||||
message: '同步模式完成',
|
||||
submitData: submit.data,
|
||||
statusData: submit.data,
|
||||
downloadData: { kind: 'json', data: submit.data }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const jobId = submit.data?.job_id || submit.data?.jobId;
|
||||
if (protocolMode === 'sync' && !isSyncPayload) {
|
||||
throw new Error('当前协议模式为仅同步,但后端返回非同步结构');
|
||||
}
|
||||
if (!jobId) throw new Error('后端返回成功但无 job_id / files');
|
||||
Store.upsertTask({ id: taskId, jobId, submitData: submit.data, message: `任务已提交: ${jobId}` });
|
||||
|
||||
for (let round = 1; round <= MAX_POLL_ROUNDS; round++) {
|
||||
const st = await Api.request(`/job/${encodeURIComponent(jobId)}/status`, { method: 'GET' });
|
||||
if (!st.ok) throw new Error(`状态查询失败(${st.status}): ${(st.text || '').slice(0, 160)}`);
|
||||
|
||||
if (st.data && st.data.success === false && /unauthorized/i.test(st.data.message || '')) {
|
||||
throw new Error('状态接口鉴权失败(Unauthorized)');
|
||||
}
|
||||
|
||||
const status = st.data?.status || 'processing';
|
||||
Store.upsertTask({ id: taskId, statusData: st.data, message: `状态: ${status} (${round}/${MAX_POLL_ROUNDS})` });
|
||||
|
||||
if (status === 'completed') {
|
||||
const dl = await fetchDownloadPayload(jobId);
|
||||
persistAsset(Store.getTask(taskId) || task, st.data, dl);
|
||||
Store.upsertTask({ id: taskId, status: 'done', message: '转换完成', downloadData: dl });
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 'failed' || status === 'timeout') throw new Error(`任务结束状态: ${status}`);
|
||||
await wait(POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
throw new Error('轮询超时,请稍后重试');
|
||||
} catch (err) {
|
||||
Store.upsertTask({ id: taskId, status: 'error', message: err.message || '未知错误' });
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDownloadPayload(jobId) {
|
||||
const res = await Api.request(`/job/${encodeURIComponent(jobId)}/download`, { method: 'GET' });
|
||||
if (!res.ok) throw new Error(`下载接口失败(${res.status})`);
|
||||
if (res.data) return { kind: 'json', data: res.data };
|
||||
const blob = new Blob([res.text || ''], { type: 'text/plain' });
|
||||
return { kind: 'blob', blob, filename: `convertx-${jobId}.txt` };
|
||||
}
|
||||
|
||||
function initConverterTool() {
|
||||
const container = document.getElementById('tool-converter-container');
|
||||
if (!container) return;
|
||||
|
||||
const fileInput = document.getElementById('file-input');
|
||||
const dropZone = container.querySelector('.drop-zone');
|
||||
const baseUrlEl = document.getElementById('convertx-base-url');
|
||||
const apiKeyEl = document.getElementById('api-key');
|
||||
if (baseUrlEl && !baseUrlEl.value) baseUrlEl.value = DEFAULT_BASE_URL;
|
||||
if (apiKeyEl && !apiKeyEl.value) apiKeyEl.value = DEFAULT_API_KEY;
|
||||
|
||||
fileInput.addEventListener('change', (e) => handleFileSelect(e.target.files));
|
||||
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.style.background = '#eff6ff';
|
||||
});
|
||||
dropZone.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.style.background = 'white';
|
||||
});
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.style.background = 'white';
|
||||
if (!e.dataTransfer.files.length) return;
|
||||
fileInput.files = e.dataTransfer.files;
|
||||
handleFileSelect(e.dataTransfer.files);
|
||||
});
|
||||
|
||||
window.filterFormats = function (type) {
|
||||
document.querySelectorAll('.format-tab').forEach((t) => t.classList.remove('active'));
|
||||
if (window.event?.target) window.event.target.classList.add('active');
|
||||
document.querySelectorAll('.format-item').forEach((item) => {
|
||||
item.style.display = (type === 'all' || item.dataset.type === type) ? 'flex' : 'none';
|
||||
});
|
||||
};
|
||||
|
||||
window.selectFormat = function (fmt) {
|
||||
if (!Store.state.files.length) {
|
||||
alert('请先选择文件');
|
||||
return;
|
||||
}
|
||||
Store.setTargetFormat(fmt);
|
||||
document.getElementById('target-format-name').innerText = fmt.toUpperCase();
|
||||
document.getElementById('action-bar').style.display = 'block';
|
||||
suggestConverterForTarget(fmt);
|
||||
document.querySelectorAll('.format-item').forEach((el) => el.classList.remove('selected'));
|
||||
if (window.event?.currentTarget) window.event.currentTarget.classList.add('selected');
|
||||
};
|
||||
|
||||
window.startConversion = startBatchConversion;
|
||||
window.startBatchConversion = startBatchConversion;
|
||||
window.retryFailedTasks = retryFailedTasks;
|
||||
window.clearTaskQueue = clearTaskQueue;
|
||||
window.downloadResult = downloadResult;
|
||||
window.filterTaskView = renderAllTasks;
|
||||
window.exportDiagnostics = exportDiagnostics;
|
||||
window.clearDebugLogs = clearDebugLogs;
|
||||
window.refreshAssetCenter = renderAssetCenter;
|
||||
window.clearAssetCenter = clearAssetCenter;
|
||||
window.checkBackendConnectivity = refreshBackendMeta;
|
||||
|
||||
Bus.on('task:updated', renderTaskRow);
|
||||
Bus.on('queue:reset', renderQueueEmpty);
|
||||
|
||||
setBackendStatus('未检测后端(点击“手动检测后端”)', 'warn');
|
||||
renderAssetCenter();
|
||||
}
|
||||
|
||||
function handleFileSelect(fileList) {
|
||||
const files = Array.from(fileList || []);
|
||||
if (!files.length) return;
|
||||
Store.setFiles(files);
|
||||
|
||||
const selectedFileName = document.getElementById('selected-file-name');
|
||||
const uploadTitle = document.querySelector('.upload-section h3');
|
||||
if (selectedFileName) selectedFileName.innerText = files[0].name;
|
||||
if (uploadTitle) {
|
||||
uploadTitle.innerText = files.length === 1
|
||||
? `已选择: ${files[0].name}`
|
||||
: `已选择 ${files.length} 个文件(首个: ${files[0].name})`;
|
||||
}
|
||||
}
|
||||
|
||||
async function startBatchConversion() {
|
||||
if (!Store.state.files.length || !Store.state.targetFormat) return;
|
||||
Store.state.files.forEach((file) => {
|
||||
const task = createTaskFromFile(file);
|
||||
Store.upsertTask(task);
|
||||
});
|
||||
runScheduler();
|
||||
}
|
||||
|
||||
function retryFailedTasks() {
|
||||
Store.getTasks().filter((t) => t.status === 'error').forEach((t) => {
|
||||
Store.upsertTask({ id: t.id, status: 'pending', message: '重试排队中...' });
|
||||
});
|
||||
runScheduler();
|
||||
}
|
||||
|
||||
function clearTaskQueue() {
|
||||
Store.clearTasks();
|
||||
}
|
||||
|
||||
async function downloadResult(id) {
|
||||
const task = Store.getTask(id);
|
||||
if (!task) return;
|
||||
try {
|
||||
const payload = task.downloadData || (task.jobId ? await fetchDownloadPayload(task.jobId) : { kind: 'json', data: task.submitData || {} });
|
||||
if (payload.kind === 'blob') {
|
||||
saveAs(payload.blob, payload.filename || `result-${id}.bin`);
|
||||
} else {
|
||||
const blob = new Blob([JSON.stringify(payload.data || {}, null, 2)], { type: 'application/json' });
|
||||
saveAs(blob, `convertx-${id}-result.json`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`下载失败: ${err.message || err}`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTaskRow(task) {
|
||||
const filter = (document.getElementById('task-filter')?.value || 'all');
|
||||
|
||||
const q = document.getElementById('conversion-queue');
|
||||
if (!q) return;
|
||||
if (q.children[0] && q.children[0].innerText.includes('暂无任务')) q.innerHTML = '';
|
||||
|
||||
let row = document.getElementById(`task-${task.id}`);
|
||||
if (!row) {
|
||||
row = document.createElement('div');
|
||||
row.className = 'queue-item';
|
||||
row.id = `task-${task.id}`;
|
||||
q.prepend(row);
|
||||
}
|
||||
|
||||
if (filter !== 'all' && task.status !== filter) {
|
||||
row.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
row.style.display = 'flex';
|
||||
|
||||
const statusClass = task.status === 'done' ? 'status-done'
|
||||
: task.status === 'error' ? 'status-error'
|
||||
: task.status === 'running' ? 'status-processing'
|
||||
: 'status-pending';
|
||||
|
||||
const action = task.status === 'done'
|
||||
? `完成 <a href="#" onclick="downloadResult(${task.id});return false;">下载</a>`
|
||||
: task.status === 'error'
|
||||
? `失败: ${task.message || '未知错误'}`
|
||||
: (task.message || '处理中...');
|
||||
|
||||
row.innerHTML = `<div><strong>${task.filename}</strong> <span style="color:#999">→ ${(task.targetFormat || '').toUpperCase()}</span> <span style="font-size:11px;color:#bbb">#${task.attempts || 0}</span></div><div class="queue-status ${statusClass}">${action}</div>`;
|
||||
}
|
||||
|
||||
function renderAllTasks() {
|
||||
const q = document.getElementById('conversion-queue');
|
||||
if (!q) return;
|
||||
q.innerHTML = '<div class="queue-item" style="color:#999; justify-content:center;">暂无任务</div>';
|
||||
Store.getTasks().forEach((task) => renderTaskRow(task));
|
||||
}
|
||||
|
||||
function renderQueueEmpty() {
|
||||
const q = document.getElementById('conversion-queue');
|
||||
if (!q) return;
|
||||
q.innerHTML = '<div class="queue-item" style="color:#999; justify-content:center;">暂无任务</div>';
|
||||
}
|
||||
|
||||
async function refreshBackendMeta() {
|
||||
try {
|
||||
const health = await Api.request('/health', { method: 'GET', useApiKey: false });
|
||||
if (!health.ok) throw new Error(`health ${health.status}`);
|
||||
|
||||
const converters = await Api.request('/converters', { method: 'GET', useApiKey: false });
|
||||
if (converters.ok && converters.data) {
|
||||
Store.state.converterCapabilities = converters.data || {};
|
||||
const names = Object.keys(converters.data || {});
|
||||
setBackendStatus(`后端在线,已加载 ${names.length} 个转换器`, 'ok');
|
||||
} else {
|
||||
setBackendStatus('初始化告警:转换器列表不可用,仍可尝试手动转换', 'warn');
|
||||
}
|
||||
} catch (err) {
|
||||
setBackendStatus(`初始化告警: ${err.message || err}(不阻断,可继续尝试转换)`, 'warn');
|
||||
debugLog('init.non_blocking_warning', {
|
||||
reason: String(err?.message || err),
|
||||
hint: '请检查 CSP connect-src / CORS / 代理策略'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getRecommendedConverter(targetFormat) {
|
||||
const caps = Store.state.converterCapabilities || {};
|
||||
const names = Object.keys(caps);
|
||||
for (const name of names) {
|
||||
const targets = caps[name]?.targets || [];
|
||||
if (targets.includes(targetFormat)) return name;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function suggestConverterForTarget(targetFormat) {
|
||||
const input = document.getElementById('converter-name');
|
||||
if (!input || input.value.trim()) return;
|
||||
const rec = getRecommendedConverter(targetFormat);
|
||||
if (rec) input.value = rec;
|
||||
}
|
||||
|
||||
function getAssetList() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(ASSET_STORAGE_KEY) || '[]');
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function setAssetList(list) {
|
||||
localStorage.setItem(ASSET_STORAGE_KEY, JSON.stringify(list));
|
||||
}
|
||||
|
||||
function persistAsset(task, statusData, downloadData) {
|
||||
const list = getAssetList();
|
||||
const item = {
|
||||
id: task.id,
|
||||
filename: task.filename,
|
||||
targetFormat: task.targetFormat,
|
||||
converter: task.converter,
|
||||
jobId: task.jobId || null,
|
||||
createdAt: Date.now(),
|
||||
statusData: statusData || null,
|
||||
hasDownloadBlob: Boolean(downloadData && downloadData.kind === 'blob')
|
||||
};
|
||||
list.unshift(item);
|
||||
setAssetList(list.slice(0, 500));
|
||||
renderAssetCenter();
|
||||
}
|
||||
|
||||
function renderAssetCenter() {
|
||||
const el = document.getElementById('asset-center-list');
|
||||
if (!el) return;
|
||||
const list = getAssetList();
|
||||
if (!list.length) {
|
||||
el.innerHTML = '暂无本地资产';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = list.slice(0, 50).map((x) => {
|
||||
const t = new Date(x.createdAt || Date.now()).toLocaleString();
|
||||
return `<div style="padding:6px 0; border-bottom:1px dashed #eee;"><strong>${x.filename}</strong> → ${String(x.targetFormat || '').toUpperCase()} <span style="color:#999; font-size:12px;">${t}</span></div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function clearAssetCenter() {
|
||||
localStorage.removeItem(ASSET_STORAGE_KEY);
|
||||
renderAssetCenter();
|
||||
}
|
||||
|
||||
function exportDiagnostics() {
|
||||
const payload = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
baseUrl: Api.getBaseUrl(),
|
||||
taskCount: Store.getTasks().length,
|
||||
tasks: Store.getTasks(),
|
||||
assets: getAssetList(),
|
||||
logs: window.__convertxDebugLogs || []
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
saveAs(blob, `convertx-diagnostics-${Date.now()}.json`);
|
||||
}
|
||||
|
||||
function clearDebugLogs() {
|
||||
window.__convertxDebugLogs = [];
|
||||
const panel = document.getElementById('debug-log-panel');
|
||||
if (panel) panel.innerHTML = '<div style="color:#6b7280;">暂无日志</div>';
|
||||
}
|
||||
|
||||
function setBackendStatus(text, type) {
|
||||
const el = document.getElementById('backend-status');
|
||||
if (!el) return;
|
||||
const color = type === 'ok' ? '#16a34a' : type === 'warn' ? '#d97706' : '#ef4444';
|
||||
el.style.color = color;
|
||||
el.innerText = text;
|
||||
}
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function debugLog(event, payload) {
|
||||
try {
|
||||
const entry = { ts: new Date().toISOString(), event, payload: payload || {} };
|
||||
window.__convertxDebugLogs = window.__convertxDebugLogs || [];
|
||||
window.__convertxDebugLogs.push(entry);
|
||||
if (window.__convertxDebugLogs.length > DEBUG_LOG_LIMIT) window.__convertxDebugLogs.shift();
|
||||
|
||||
const panel = document.getElementById('debug-log-panel');
|
||||
if (panel) {
|
||||
if (panel.innerText.includes('暂无日志')) panel.innerHTML = '';
|
||||
const line = document.createElement('div');
|
||||
line.textContent = `[${entry.ts}] ${event} ${JSON.stringify(entry.payload)}`;
|
||||
panel.appendChild(line);
|
||||
panel.scrollTop = panel.scrollHeight;
|
||||
}
|
||||
console.log(`${DEBUG_PREFIX} ${event}`, payload || {});
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initConverterTool);
|
||||
} else {
|
||||
initConverterTool();
|
||||
}
|
||||
})();
|
||||
156
js/tools/geojson.js
Normal file
156
js/tools/geojson.js
Normal file
@@ -0,0 +1,156 @@
|
||||
(function() {
|
||||
let map = null;
|
||||
let geoJsonLayer = null;
|
||||
const API_BASE = 'https://geo.datav.aliyun.com/areas_v3/bound/geojson?code=';
|
||||
|
||||
function initGeoJsonTool() {
|
||||
const container = document.getElementById('tool-geojson-container');
|
||||
if (!container) return;
|
||||
|
||||
// Stop click propagation to prevent global theme effects
|
||||
container.addEventListener('click', (e) => e.stopPropagation());
|
||||
|
||||
const input = document.getElementById('geojson-input');
|
||||
const btnLoad = document.getElementById('btn-load');
|
||||
const btnDownload = document.getElementById('btn-download-json');
|
||||
const btnQuery = document.getElementById('btn-query-area');
|
||||
const selectProv = document.getElementById('select-prov');
|
||||
const selectCity = document.getElementById('select-city');
|
||||
const selectDist = document.getElementById('select-dist');
|
||||
const errorMsg = document.getElementById('error-msg');
|
||||
|
||||
if (map) { map.remove(); map = null; }
|
||||
|
||||
if (typeof L === 'undefined') {
|
||||
const checkL = setInterval(() => {
|
||||
if (typeof L !== 'undefined') {
|
||||
clearInterval(checkL);
|
||||
initMap();
|
||||
}
|
||||
}, 200);
|
||||
} else {
|
||||
initMap();
|
||||
}
|
||||
|
||||
// Init Data
|
||||
fetchDistricts('100000', selectProv);
|
||||
|
||||
function initMap() {
|
||||
if (map) return;
|
||||
// Remove attribution
|
||||
map = L.map('map', { attributionControl: false }).setView([35.8617, 104.1954], 4);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
|
||||
}
|
||||
|
||||
async function fetchDistricts(adcode, targetSelect) {
|
||||
try {
|
||||
targetSelect.innerHTML = '<option value="">加载中...</option>';
|
||||
const url = `${API_BASE}${adcode}_full`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error('Fetch failed');
|
||||
const data = await res.json();
|
||||
|
||||
targetSelect.innerHTML = '<option value="">请选择</option>';
|
||||
data.features.forEach(f => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = f.properties.adcode;
|
||||
opt.innerText = f.properties.name;
|
||||
targetSelect.appendChild(opt);
|
||||
});
|
||||
targetSelect.disabled = false;
|
||||
} catch (e) {
|
||||
targetSelect.innerHTML = '<option value="">无下级/未找到</option>';
|
||||
targetSelect.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
selectProv.addEventListener('change', () => {
|
||||
selectCity.innerHTML = '<option value="">选择城市</option>';
|
||||
selectDist.innerHTML = '<option value="">选择区县</option>';
|
||||
if (selectProv.value) fetchDistricts(selectProv.value, selectCity);
|
||||
});
|
||||
|
||||
selectCity.addEventListener('change', () => {
|
||||
selectDist.innerHTML = '<option value="">选择区县</option>';
|
||||
if (selectCity.value) fetchDistricts(selectCity.value, selectDist);
|
||||
});
|
||||
|
||||
btnQuery.addEventListener('click', async () => {
|
||||
const code = selectDist.value || selectCity.value || selectProv.value;
|
||||
if (!code) {
|
||||
alert('请先选择一个区域');
|
||||
return;
|
||||
}
|
||||
btnQuery.disabled = true;
|
||||
btnQuery.innerText = '查询中...';
|
||||
|
||||
let data = null;
|
||||
try {
|
||||
// Try _full first for children details
|
||||
const res = await fetch(`${API_BASE}${code}_full`);
|
||||
if (res.ok) data = await res.json();
|
||||
else {
|
||||
// Fallback to boundary only
|
||||
const res2 = await fetch(`${API_BASE}${code}`);
|
||||
if (res2.ok) data = await res2.json();
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
btnQuery.disabled = false;
|
||||
btnQuery.innerText = '查询区域';
|
||||
|
||||
if (data) {
|
||||
input.value = JSON.stringify(data, null, 2);
|
||||
loadGeoJSON();
|
||||
} else {
|
||||
alert('获取数据失败,请稍后重试');
|
||||
}
|
||||
});
|
||||
|
||||
function loadGeoJSON() {
|
||||
const val = input.value.trim();
|
||||
errorMsg.style.display = 'none';
|
||||
if (!val) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(val);
|
||||
if (geoJsonLayer) map.removeLayer(geoJsonLayer);
|
||||
|
||||
geoJsonLayer = L.geoJSON(data, {
|
||||
onEachFeature: function (feature, layer) {
|
||||
if (feature.properties) {
|
||||
let popup = '<div style="max-height: 200px; overflow-y: auto;"><table>';
|
||||
for (const k in feature.properties) {
|
||||
popup += `<tr><td><strong>${k}</strong></td><td>${feature.properties[k]}</td></tr>`;
|
||||
}
|
||||
popup += '</table></div>';
|
||||
layer.bindPopup(popup);
|
||||
}
|
||||
}
|
||||
}).addTo(map);
|
||||
|
||||
const bounds = geoJsonLayer.getBounds();
|
||||
if (bounds.isValid()) map.fitBounds(bounds);
|
||||
|
||||
} catch (e) {
|
||||
errorMsg.innerText = 'JSON 解析错误: ' + e.message;
|
||||
errorMsg.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
btnLoad.addEventListener('click', loadGeoJSON);
|
||||
|
||||
btnDownload.addEventListener('click', () => {
|
||||
if (!input.value) return;
|
||||
const blob = new Blob([input.value], {type: "application/json"});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = "data.geojson";
|
||||
a.click();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initGeoJsonTool);
|
||||
document.addEventListener('pjax:complete', initGeoJsonTool);
|
||||
})();
|
||||
96
js/tools/gif.js
Normal file
96
js/tools/gif.js
Normal file
@@ -0,0 +1,96 @@
|
||||
(function() {
|
||||
let images = [];
|
||||
|
||||
function initGifTool() {
|
||||
const container = document.getElementById('tool-gif-container');
|
||||
if (!container) return;
|
||||
|
||||
container.addEventListener('click', (e) => e.stopPropagation());
|
||||
|
||||
const fileInput = document.getElementById('file-input');
|
||||
const imgList = document.getElementById('img-list');
|
||||
const btnCreate = document.getElementById('btn-create');
|
||||
const resultGif = document.getElementById('result-gif');
|
||||
const downloadLink = document.getElementById('download-link');
|
||||
const uploadBox = document.getElementById('upload-box');
|
||||
|
||||
uploadBox.addEventListener('click', () => fileInput.click());
|
||||
|
||||
fileInput.addEventListener('change', handleFiles);
|
||||
|
||||
function handleFiles(e) {
|
||||
const files = Array.from(e.target.files);
|
||||
if (!files.length) return;
|
||||
|
||||
files.forEach(file => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
images.push(event.target.result);
|
||||
renderPreview();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function renderPreview() {
|
||||
imgList.innerHTML = '';
|
||||
images.forEach(src => {
|
||||
const img = document.createElement('img');
|
||||
img.src = src;
|
||||
img.className = 'img-item';
|
||||
img.style.width = '80px';
|
||||
img.style.height = '80px';
|
||||
img.style.objectFit = 'cover';
|
||||
img.style.borderRadius = '4px';
|
||||
img.style.border = '1px solid #ddd';
|
||||
imgList.appendChild(img);
|
||||
});
|
||||
}
|
||||
|
||||
btnCreate.addEventListener('click', createGIF);
|
||||
|
||||
function createGIF() {
|
||||
if (images.length === 0) {
|
||||
alert('请先上传图片');
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof gifshot === 'undefined') {
|
||||
alert('GIF 库未加载,请检查网络');
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = parseFloat(document.getElementById('interval').value);
|
||||
const width = parseInt(document.getElementById('gif-width').value);
|
||||
const height = parseInt(document.getElementById('gif-height').value);
|
||||
|
||||
btnCreate.innerText = '生成中...';
|
||||
btnCreate.disabled = true;
|
||||
|
||||
gifshot.createGIF({
|
||||
images: images,
|
||||
interval: interval,
|
||||
gifWidth: width,
|
||||
gifHeight: height,
|
||||
numFrames: images.length
|
||||
}, function(obj) {
|
||||
if(!obj.error) {
|
||||
const image = obj.image;
|
||||
resultGif.src = image;
|
||||
resultGif.style.display = 'inline-block';
|
||||
|
||||
downloadLink.href = image;
|
||||
downloadLink.download = 'animation.gif';
|
||||
downloadLink.style.display = 'inline-block';
|
||||
} else {
|
||||
alert('生成失败');
|
||||
}
|
||||
btnCreate.innerText = '生成 GIF';
|
||||
btnCreate.disabled = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initGifTool);
|
||||
document.addEventListener('pjax:complete', initGifTool);
|
||||
})();
|
||||
62
js/tools/hash.js
Normal file
62
js/tools/hash.js
Normal file
@@ -0,0 +1,62 @@
|
||||
(function() {
|
||||
function initHashTool() {
|
||||
const container = document.getElementById('tool-hash-container');
|
||||
if (!container) return;
|
||||
|
||||
container.addEventListener('click', (e) => e.stopPropagation());
|
||||
|
||||
const inputText = document.getElementById('input-text');
|
||||
const resMd5 = document.getElementById('res-md5');
|
||||
const resSha1 = document.getElementById('res-sha1');
|
||||
const resSha256 = document.getElementById('res-sha256');
|
||||
const resSha512 = document.getElementById('res-sha512');
|
||||
const copyBtns = document.querySelectorAll('.copy-btn');
|
||||
|
||||
function calculateHash() {
|
||||
const text = inputText.value;
|
||||
if (!text) {
|
||||
resMd5.innerText = '-';
|
||||
resSha1.innerText = '-';
|
||||
resSha256.innerText = '-';
|
||||
resSha512.innerText = '-';
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof CryptoJS === 'undefined') {
|
||||
resMd5.innerText = '加载核心库失败,请检查网络...';
|
||||
return;
|
||||
}
|
||||
|
||||
resMd5.innerText = CryptoJS.MD5(text).toString();
|
||||
resSha1.innerText = CryptoJS.SHA1(text).toString();
|
||||
resSha256.innerText = CryptoJS.SHA256(text).toString();
|
||||
resSha512.innerText = CryptoJS.SHA512(text).toString();
|
||||
}
|
||||
|
||||
inputText.addEventListener('input', calculateHash);
|
||||
|
||||
copyBtns.forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const targetId = e.target.getAttribute('data-copy-target');
|
||||
const text = document.getElementById(targetId).innerText;
|
||||
if (text === '-' || text.includes('失败')) return;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const originalText = e.target.innerText;
|
||||
e.target.innerText = '已复制';
|
||||
setTimeout(() => e.target.innerText = originalText, 1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Check if library loaded
|
||||
const checkLib = setInterval(() => {
|
||||
if (typeof CryptoJS !== 'undefined') {
|
||||
clearInterval(checkLib);
|
||||
calculateHash();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initHashTool);
|
||||
document.addEventListener('pjax:complete', initHashTool);
|
||||
})();
|
||||
122
js/tools/mirage-tank.js
Normal file
122
js/tools/mirage-tank.js
Normal file
@@ -0,0 +1,122 @@
|
||||
(function() {
|
||||
let surfaceImg = null;
|
||||
let hiddenImg = null;
|
||||
|
||||
function initMirageTankTool() {
|
||||
const container = document.getElementById('tool-mirage-container');
|
||||
if (!container) return;
|
||||
|
||||
container.addEventListener('click', (e) => e.stopPropagation());
|
||||
|
||||
const imgSurfaceInput = document.getElementById('img-surface');
|
||||
const imgHiddenInput = document.getElementById('img-hidden');
|
||||
const btnGenerate = document.getElementById('generate-btn');
|
||||
const btnDownload = document.getElementById('btn-download');
|
||||
const canvas = document.getElementById('result-canvas');
|
||||
const resultArea = document.getElementById('result-area');
|
||||
const bgBtns = document.querySelectorAll('.preview-bg-btn');
|
||||
|
||||
function handleFile(input, imgId, textId, type) {
|
||||
input.addEventListener('change', (e) => {
|
||||
if (!e.target.files.length) return;
|
||||
const file = e.target.files[0];
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
document.getElementById(imgId).src = img.src;
|
||||
document.getElementById(imgId).style.display = 'block';
|
||||
document.getElementById(textId).style.display = 'none';
|
||||
if (type === 'surface') surfaceImg = img;
|
||||
else hiddenImg = img;
|
||||
checkReady();
|
||||
};
|
||||
img.src = event.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function checkReady() {
|
||||
btnGenerate.disabled = !(surfaceImg && hiddenImg);
|
||||
}
|
||||
|
||||
function generateMirageTank() {
|
||||
if (!surfaceImg || !hiddenImg) return;
|
||||
|
||||
const width = Math.max(surfaceImg.width, hiddenImg.width);
|
||||
const height = Math.max(surfaceImg.height, hiddenImg.height);
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const cvs1 = document.createElement('canvas');
|
||||
cvs1.width = width; cvs1.height = height;
|
||||
const ctx1 = cvs1.getContext('2d');
|
||||
ctx1.fillStyle = '#fff';
|
||||
ctx1.fillRect(0, 0, width, height);
|
||||
ctx1.drawImage(surfaceImg, 0, 0, width, height);
|
||||
const data1 = ctx1.getImageData(0, 0, width, height).data;
|
||||
|
||||
const cvs2 = document.createElement('canvas');
|
||||
cvs2.width = width; cvs2.height = height;
|
||||
const ctx2 = cvs2.getContext('2d');
|
||||
ctx2.fillStyle = '#000';
|
||||
ctx2.fillRect(0, 0, width, height);
|
||||
ctx2.drawImage(hiddenImg, 0, 0, width, height);
|
||||
const data2 = ctx2.getImageData(0, 0, width, height).data;
|
||||
|
||||
const resultData = ctx.createImageData(width, height);
|
||||
const d = resultData.data;
|
||||
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const r1 = data1[i], g1 = data1[i+1], b1 = data1[i+2];
|
||||
const gray1 = 0.299 * r1 + 0.587 * g1 + 0.114 * b1;
|
||||
|
||||
const r2 = data2[i], g2 = data2[i+1], b2 = data2[i+2];
|
||||
const gray2 = 0.299 * r2 + 0.587 * g2 + 0.114 * b2;
|
||||
|
||||
let a = (gray2 - gray1 + 255) / 255;
|
||||
if (a < 0) a = 0;
|
||||
if (a > 1) a = 1;
|
||||
|
||||
let p = 0;
|
||||
if (a > 0.01) p = gray2 / a;
|
||||
if (p > 255) p = 255;
|
||||
|
||||
d[i] = p;
|
||||
d[i+1] = p;
|
||||
d[i+2] = p;
|
||||
d[i+3] = a * 255;
|
||||
}
|
||||
|
||||
ctx.putImageData(resultData, 0, 0);
|
||||
resultArea.style.display = 'block';
|
||||
}
|
||||
|
||||
handleFile(imgSurfaceInput, 'preview-surface-img', 'preview-surface-text', 'surface');
|
||||
handleFile(imgHiddenInput, 'preview-hidden-img', 'preview-hidden-text', 'hidden');
|
||||
|
||||
btnGenerate.addEventListener('click', generateMirageTank);
|
||||
|
||||
bgBtns.forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const type = e.target.getAttribute('data-bg');
|
||||
if (type === 'white') canvas.style.background = 'white';
|
||||
else if (type === 'black') canvas.style.background = 'black';
|
||||
else canvas.style.background = "url('data:image/svg+xml;utf8,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"20\" viewBox=\"0 0 20 20\"><rect width=\"10\" height=\"10\" fill=\"%23ccc\"/><rect x=\"10\" y=\"10\" width=\"10\" height=\"10\" fill=\"%23ccc\"/></svg>') repeat";
|
||||
});
|
||||
});
|
||||
|
||||
btnDownload.addEventListener('click', () => {
|
||||
const link = document.createElement('a');
|
||||
link.download = 'mirage-tank.png';
|
||||
link.href = canvas.toDataURL();
|
||||
link.click();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initMirageTankTool);
|
||||
document.addEventListener('pjax:complete', initMirageTankTool);
|
||||
})();
|
||||
103
js/tools/remove-bg.js
Normal file
103
js/tools/remove-bg.js
Normal file
@@ -0,0 +1,103 @@
|
||||
(function() {
|
||||
let originalImg = null;
|
||||
let targetR=255, targetG=255, targetB=255;
|
||||
|
||||
function initRemoveBgTool() {
|
||||
const container = document.getElementById('tool-removebg-container');
|
||||
if (!container) return;
|
||||
|
||||
container.addEventListener('click', (e) => e.stopPropagation());
|
||||
|
||||
const canvas = document.getElementById('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const fileInput = document.getElementById('file-input');
|
||||
const toleranceInput = document.getElementById('tolerance');
|
||||
const tolVal = document.getElementById('tol-val');
|
||||
const targetColorBox = document.getElementById('target-color');
|
||||
const targetColorText = document.getElementById('target-color-text');
|
||||
const btnReset = document.getElementById('btn-reset');
|
||||
const btnDownload = document.getElementById('btn-download');
|
||||
const uploadBox = document.getElementById('upload-box');
|
||||
|
||||
uploadBox.addEventListener('click', () => fileInput.click());
|
||||
|
||||
fileInput.addEventListener('change', function(e) {
|
||||
if (!e.target.files.length) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
originalImg = img;
|
||||
renderImage();
|
||||
};
|
||||
img.src = ev.target.result;
|
||||
};
|
||||
reader.readAsDataURL(e.target.files[0]);
|
||||
});
|
||||
|
||||
function renderImage() {
|
||||
if (!originalImg) return;
|
||||
canvas.width = originalImg.width;
|
||||
canvas.height = originalImg.height;
|
||||
ctx.drawImage(originalImg, 0, 0);
|
||||
}
|
||||
|
||||
btnReset.addEventListener('click', renderImage);
|
||||
|
||||
canvas.addEventListener('click', function(e) {
|
||||
if (!originalImg) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
|
||||
const x = (e.clientX - rect.left) * scaleX;
|
||||
const y = (e.clientY - rect.top) * scaleY;
|
||||
|
||||
const p = ctx.getImageData(x, y, 1, 1).data;
|
||||
targetR = p[0];
|
||||
targetG = p[1];
|
||||
targetB = p[2];
|
||||
|
||||
const hex = "#" + ((1 << 24) + (targetR << 16) + (targetG << 8) + targetB).toString(16).slice(1);
|
||||
targetColorBox.style.background = hex;
|
||||
targetColorText.innerText = hex;
|
||||
|
||||
processRemoval();
|
||||
});
|
||||
|
||||
function processRemoval() {
|
||||
if (!originalImg) return;
|
||||
ctx.drawImage(originalImg, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
const tolerance = parseInt(toleranceInput.value);
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i], g = data[i+1], b = data[i+2];
|
||||
const dist = Math.sqrt(Math.pow(r - targetR, 2) + Math.pow(g - targetG, 2) + Math.pow(b - targetB, 2));
|
||||
|
||||
if (dist < tolerance * 1.5) {
|
||||
data[i+3] = 0;
|
||||
}
|
||||
}
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
toleranceInput.addEventListener('input', () => {
|
||||
tolVal.innerText = toleranceInput.value;
|
||||
});
|
||||
toleranceInput.addEventListener('change', processRemoval);
|
||||
|
||||
btnDownload.addEventListener('click', () => {
|
||||
const link = document.createElement('a');
|
||||
link.download = 'removed-bg.png';
|
||||
link.href = canvas.toDataURL();
|
||||
link.click();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initRemoveBgTool);
|
||||
document.addEventListener('pjax:complete', initRemoveBgTool);
|
||||
})();
|
||||
622
js/tools/sort.js
Normal file
622
js/tools/sort.js
Normal file
@@ -0,0 +1,622 @@
|
||||
/**
|
||||
* 排序算法演示逻辑 (15种算法)
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const container = document.getElementById('sort-container');
|
||||
const sizeInput = document.getElementById('array-size');
|
||||
const algorithmSelect = document.getElementById('algorithm-select');
|
||||
const speedSelect = document.getElementById('speed-select');
|
||||
const btnGenerate = document.getElementById('btn-generate');
|
||||
const btnSort = document.getElementById('btn-sort');
|
||||
|
||||
let array = [];
|
||||
let isSorting = false;
|
||||
let cancelSort = false;
|
||||
|
||||
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
function generateArray() {
|
||||
if (isSorting) return;
|
||||
|
||||
container.innerHTML = '';
|
||||
array = [];
|
||||
let size = parseInt(sizeInput.value);
|
||||
if (isNaN(size) || size < 10) size = 10;
|
||||
if (size > 100) size = 100;
|
||||
sizeInput.value = size;
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
const value = Math.floor(Math.random() * 290) + 10;
|
||||
array.push(value);
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.classList.add('sort-bar');
|
||||
bar.style.height = `${value}px`;
|
||||
const barWidth = Math.max(2, Math.floor((container.clientWidth - size * 2) / size));
|
||||
bar.style.width = `${barWidth}px`;
|
||||
container.appendChild(bar);
|
||||
}
|
||||
}
|
||||
|
||||
function updateBar(index, height, colorClass) {
|
||||
const bars = container.children;
|
||||
if (bars[index]) {
|
||||
if (height !== null) bars[index].style.height = `${height}px`;
|
||||
bars[index].classList.remove('comparing', 'swapping', 'sorted');
|
||||
if (colorClass) bars[index].classList.add(colorClass);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Algorithm Implementations ---
|
||||
|
||||
async function bubbleSort() {
|
||||
const n = array.length;
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
for (let j = 0; j < n - i - 1; j++) {
|
||||
if (cancelSort) return;
|
||||
updateBar(j, null, 'comparing');
|
||||
updateBar(j + 1, null, 'comparing');
|
||||
await sleep(parseInt(speedSelect.value));
|
||||
if (array[j] > array[j + 1]) {
|
||||
updateBar(j, null, 'swapping');
|
||||
updateBar(j + 1, null, 'swapping');
|
||||
let temp = array[j]; array[j] = array[j + 1]; array[j + 1] = temp;
|
||||
updateBar(j, array[j], null);
|
||||
updateBar(j + 1, array[j + 1], null);
|
||||
} else {
|
||||
updateBar(j, null, null);
|
||||
updateBar(j + 1, null, null);
|
||||
}
|
||||
}
|
||||
updateBar(n - i - 1, null, 'sorted');
|
||||
}
|
||||
updateBar(0, null, 'sorted');
|
||||
}
|
||||
|
||||
async function selectionSort() {
|
||||
const n = array.length;
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
let minIndex = i;
|
||||
updateBar(minIndex, null, 'swapping');
|
||||
for (let j = i + 1; j < n; j++) {
|
||||
if (cancelSort) return;
|
||||
updateBar(j, null, 'comparing');
|
||||
await sleep(parseInt(speedSelect.value));
|
||||
if (array[j] < array[minIndex]) {
|
||||
if (minIndex !== i) updateBar(minIndex, null, null);
|
||||
minIndex = j;
|
||||
updateBar(minIndex, null, 'swapping');
|
||||
} else {
|
||||
updateBar(j, null, null);
|
||||
}
|
||||
}
|
||||
if (minIndex !== i) {
|
||||
updateBar(i, null, 'swapping');
|
||||
await sleep(parseInt(speedSelect.value));
|
||||
let temp = array[i]; array[i] = array[minIndex]; array[minIndex] = temp;
|
||||
updateBar(i, array[i], null);
|
||||
updateBar(minIndex, array[minIndex], null);
|
||||
} else {
|
||||
updateBar(i, null, null);
|
||||
}
|
||||
updateBar(i, null, 'sorted');
|
||||
}
|
||||
updateBar(n - 1, null, 'sorted');
|
||||
}
|
||||
|
||||
async function insertionSort() {
|
||||
const n = array.length;
|
||||
updateBar(0, null, 'sorted');
|
||||
for (let i = 1; i < n; i++) {
|
||||
if (cancelSort) return;
|
||||
let key = array[i];
|
||||
let j = i - 1;
|
||||
updateBar(i, null, 'swapping');
|
||||
await sleep(parseInt(speedSelect.value));
|
||||
while (j >= 0 && array[j] > key) {
|
||||
if (cancelSort) return;
|
||||
updateBar(j, null, 'comparing');
|
||||
await sleep(parseInt(speedSelect.value));
|
||||
array[j + 1] = array[j];
|
||||
updateBar(j + 1, array[j + 1], 'sorted');
|
||||
updateBar(j, null, 'swapping');
|
||||
j = j - 1;
|
||||
}
|
||||
array[j + 1] = key;
|
||||
updateBar(j + 1, array[j + 1], 'sorted');
|
||||
for(let k=0; k<=i; k++) updateBar(k, null, 'sorted');
|
||||
}
|
||||
}
|
||||
|
||||
async function quickSort(start = 0, end = array.length - 1) {
|
||||
if (cancelSort) return;
|
||||
if (start >= end) {
|
||||
if(start === end && start >= 0 && start < array.length) updateBar(start, null, 'sorted');
|
||||
return;
|
||||
}
|
||||
let pivotIndex = await partition(start, end);
|
||||
if (cancelSort) return;
|
||||
updateBar(pivotIndex, null, 'sorted');
|
||||
await Promise.all([
|
||||
quickSort(start, pivotIndex - 1),
|
||||
quickSort(pivotIndex + 1, end)
|
||||
]);
|
||||
}
|
||||
async function partition(start, end) {
|
||||
let pivotValue = array[end];
|
||||
let pivotIndex = start;
|
||||
updateBar(end, null, 'swapping');
|
||||
for (let i = start; i < end; i++) {
|
||||
if (cancelSort) return start;
|
||||
updateBar(i, null, 'comparing');
|
||||
updateBar(pivotIndex, null, 'comparing');
|
||||
await sleep(parseInt(speedSelect.value));
|
||||
if (array[i] < pivotValue) {
|
||||
let temp = array[i]; array[i] = array[pivotIndex]; array[pivotIndex] = temp;
|
||||
updateBar(i, array[i], null);
|
||||
updateBar(pivotIndex, array[pivotIndex], null);
|
||||
pivotIndex++;
|
||||
} else {
|
||||
updateBar(i, null, null);
|
||||
if (pivotIndex !== i) updateBar(pivotIndex, null, null);
|
||||
}
|
||||
}
|
||||
let temp = array[pivotIndex]; array[pivotIndex] = array[end]; array[end] = temp;
|
||||
updateBar(pivotIndex, array[pivotIndex], null);
|
||||
updateBar(end, array[end], null);
|
||||
return pivotIndex;
|
||||
}
|
||||
|
||||
async function mergeSort(start = 0, end = array.length - 1) {
|
||||
if (cancelSort) return;
|
||||
if (start >= end) {
|
||||
if(start === end && start >=0) updateBar(start, null, 'sorted');
|
||||
return;
|
||||
}
|
||||
let mid = Math.floor((start + end) / 2);
|
||||
await mergeSort(start, mid);
|
||||
await mergeSort(mid + 1, end);
|
||||
await merge(start, mid, end);
|
||||
}
|
||||
async function merge(start, mid, end) {
|
||||
let temp = [];
|
||||
let i = start, j = mid + 1;
|
||||
while(i <= mid && j <= end) {
|
||||
if(cancelSort) return;
|
||||
updateBar(i, null, 'comparing');
|
||||
updateBar(j, null, 'comparing');
|
||||
await sleep(parseInt(speedSelect.value));
|
||||
if (array[i] <= array[j]) {
|
||||
temp.push(array[i]);
|
||||
updateBar(i, null, null); i++;
|
||||
} else {
|
||||
temp.push(array[j]);
|
||||
updateBar(j, null, null); j++;
|
||||
}
|
||||
}
|
||||
while(i <= mid) { temp.push(array[i]); i++; }
|
||||
while(j <= end) { temp.push(array[j]); j++; }
|
||||
for(let k = 0; k < temp.length; k++) {
|
||||
if(cancelSort) return;
|
||||
array[start + k] = temp[k];
|
||||
updateBar(start + k, array[start + k], 'swapping');
|
||||
await sleep(parseInt(speedSelect.value));
|
||||
updateBar(start + k, null, 'sorted');
|
||||
}
|
||||
}
|
||||
|
||||
async function heapSort() {
|
||||
let n = array.length;
|
||||
for(let i = Math.floor(n/2)-1; i>=0; i--){
|
||||
await heapify(n, i);
|
||||
}
|
||||
for(let i = n-1; i>0; i--){
|
||||
if(cancelSort) return;
|
||||
updateBar(0, null, 'swapping');
|
||||
updateBar(i, null, 'swapping');
|
||||
await sleep(parseInt(speedSelect.value));
|
||||
let temp = array[0]; array[0] = array[i]; array[i] = temp;
|
||||
updateBar(0, array[0], null);
|
||||
updateBar(i, array[i], 'sorted');
|
||||
await heapify(i, 0);
|
||||
}
|
||||
if(!cancelSort) updateBar(0, null, 'sorted');
|
||||
}
|
||||
async function heapify(n, i) {
|
||||
if(cancelSort) return;
|
||||
let largest = i;
|
||||
let l = 2*i + 1;
|
||||
let r = 2*i + 2;
|
||||
if (l < n) {
|
||||
updateBar(l, null, 'comparing');
|
||||
updateBar(largest, null, 'comparing');
|
||||
await sleep(parseInt(speedSelect.value));
|
||||
if(array[l] > array[largest]) largest = l;
|
||||
updateBar(l, null, null);
|
||||
updateBar(i, null, null);
|
||||
}
|
||||
if (r < n) {
|
||||
updateBar(r, null, 'comparing');
|
||||
updateBar(largest, null, 'comparing');
|
||||
await sleep(parseInt(speedSelect.value));
|
||||
if(array[r] > array[largest]) largest = r;
|
||||
updateBar(r, null, null);
|
||||
if(largest!=r) updateBar(largest, null, null);
|
||||
}
|
||||
if (largest !== i) {
|
||||
updateBar(i, null, 'swapping');
|
||||
updateBar(largest, null, 'swapping');
|
||||
await sleep(parseInt(speedSelect.value));
|
||||
let temp = array[i]; array[i] = array[largest]; array[largest] = temp;
|
||||
updateBar(i, array[i], null);
|
||||
updateBar(largest, array[largest], null);
|
||||
await heapify(n, largest);
|
||||
}
|
||||
}
|
||||
|
||||
async function shellSort() {
|
||||
let n = array.length;
|
||||
for (let gap = Math.floor(n/2); gap > 0; gap = Math.floor(gap/2)) {
|
||||
for (let i = gap; i < n; i++) {
|
||||
if(cancelSort) return;
|
||||
let temp = array[i];
|
||||
let j;
|
||||
updateBar(i, null, 'swapping');
|
||||
await sleep(parseInt(speedSelect.value));
|
||||
for (j = i; j >= gap && array[j - gap] > temp; j -= gap) {
|
||||
if(cancelSort) return;
|
||||
updateBar(j - gap, null, 'comparing');
|
||||
await sleep(parseInt(speedSelect.value));
|
||||
array[j] = array[j - gap];
|
||||
updateBar(j, array[j], 'swapping');
|
||||
updateBar(j - gap, null, null);
|
||||
}
|
||||
array[j] = temp;
|
||||
updateBar(j, array[j], null);
|
||||
updateBar(i, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cocktailShakerSort() {
|
||||
let is_swapped = true;
|
||||
let start = 0;
|
||||
let end = array.length - 1;
|
||||
while(is_swapped) {
|
||||
is_swapped = false;
|
||||
for(let i=start; i<end; i++){
|
||||
if(cancelSort) return;
|
||||
updateBar(i, null, 'comparing');
|
||||
updateBar(i+1, null, 'comparing');
|
||||
await sleep(parseInt(speedSelect.value));
|
||||
if(array[i] > array[i+1]){
|
||||
updateBar(i, null, 'swapping'); updateBar(i+1, null, 'swapping');
|
||||
let temp = array[i]; array[i] = array[i+1]; array[i+1] = temp;
|
||||
updateBar(i, array[i], null); updateBar(i+1, array[i+1], null);
|
||||
is_swapped = true;
|
||||
} else {
|
||||
updateBar(i, null, null); updateBar(i+1, null, null);
|
||||
}
|
||||
}
|
||||
if(!is_swapped) break;
|
||||
updateBar(end, null, 'sorted');
|
||||
end--;
|
||||
is_swapped = false;
|
||||
for(let i=end-1; i>=start; i--){
|
||||
if(cancelSort) return;
|
||||
updateBar(i, null, 'comparing');
|
||||
updateBar(i+1, null, 'comparing');
|
||||
await sleep(parseInt(speedSelect.value));
|
||||
if(array[i] > array[i+1]){
|
||||
updateBar(i, null, 'swapping'); updateBar(i+1, null, 'swapping');
|
||||
let temp = array[i]; array[i] = array[i+1]; array[i+1] = temp;
|
||||
updateBar(i, array[i], null); updateBar(i+1, array[i+1], null);
|
||||
is_swapped = true;
|
||||
} else {
|
||||
updateBar(i, null, null); updateBar(i+1, null, null);
|
||||
}
|
||||
}
|
||||
updateBar(start, null, 'sorted');
|
||||
start++;
|
||||
}
|
||||
}
|
||||
|
||||
async function combSort() {
|
||||
let n = array.length;
|
||||
let gap = n;
|
||||
let swapped = true;
|
||||
while(gap !== 1 || swapped) {
|
||||
if(cancelSort) return;
|
||||
gap = Math.floor(gap / 1.3);
|
||||
if(gap < 1) gap = 1;
|
||||
swapped = false;
|
||||
for(let i=0; i<n-gap; i++){
|
||||
if(cancelSort) return;
|
||||
updateBar(i, null, 'comparing');
|
||||
updateBar(i+gap, null, 'comparing');
|
||||
await sleep(parseInt(speedSelect.value));
|
||||
if(array[i] > array[i+gap]){
|
||||
updateBar(i, null, 'swapping'); updateBar(i+gap, null, 'swapping');
|
||||
let temp = array[i]; array[i] = array[i+gap]; array[i+gap] = temp;
|
||||
updateBar(i, array[i], null); updateBar(i+gap, array[i+gap], null);
|
||||
swapped = true;
|
||||
} else {
|
||||
updateBar(i, null, null); updateBar(i+gap, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function gnomeSort() {
|
||||
let index = 0;
|
||||
while(index < array.length) {
|
||||
if(cancelSort) return;
|
||||
if(index == 0) index++;
|
||||
updateBar(index, null, 'comparing');
|
||||
updateBar(index-1, null, 'comparing');
|
||||
await sleep(parseInt(speedSelect.value));
|
||||
if(array[index] >= array[index-1]){
|
||||
updateBar(index, null, null); updateBar(index-1, null, null);
|
||||
index++;
|
||||
} else {
|
||||
updateBar(index, null, 'swapping'); updateBar(index-1, null, 'swapping');
|
||||
let temp = array[index]; array[index] = array[index-1]; array[index-1] = temp;
|
||||
updateBar(index, array[index], null); updateBar(index-1, array[index-1], null);
|
||||
index--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function oddEvenSort() {
|
||||
let isSorted = false;
|
||||
while(!isSorted) {
|
||||
isSorted = true;
|
||||
for(let i=1; i<=array.length-2; i=i+2){
|
||||
if(cancelSort) return;
|
||||
updateBar(i, null, 'comparing'); updateBar(i+1, null, 'comparing');
|
||||
await sleep(parseInt(speedSelect.value));
|
||||
if(array[i] > array[i+1]){
|
||||
updateBar(i, null, 'swapping'); updateBar(i+1, null, 'swapping');
|
||||
let temp = array[i]; array[i] = array[i+1]; array[i+1] = temp;
|
||||
updateBar(i, array[i], null); updateBar(i+1, array[i+1], null);
|
||||
isSorted = false;
|
||||
} else {
|
||||
updateBar(i, null, null); updateBar(i+1, null, null);
|
||||
}
|
||||
}
|
||||
for(let i=0; i<=array.length-2; i=i+2){
|
||||
if(cancelSort) return;
|
||||
updateBar(i, null, 'comparing'); updateBar(i+1, null, 'comparing');
|
||||
await sleep(parseInt(speedSelect.value));
|
||||
if(array[i] > array[i+1]){
|
||||
updateBar(i, null, 'swapping'); updateBar(i+1, null, 'swapping');
|
||||
let temp = array[i]; array[i] = array[i+1]; array[i+1] = temp;
|
||||
updateBar(i, array[i], null); updateBar(i+1, array[i+1], null);
|
||||
isSorted = false;
|
||||
} else {
|
||||
updateBar(i, null, null); updateBar(i+1, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cycleSort() {
|
||||
let n = array.length;
|
||||
for(let cycle_start = 0; cycle_start <= n-2; cycle_start++) {
|
||||
if(cancelSort) return;
|
||||
let item = array[cycle_start];
|
||||
let pos = cycle_start;
|
||||
for(let i = cycle_start+1; i<n; i++){
|
||||
updateBar(i, null, 'comparing'); await sleep(parseInt(speedSelect.value));
|
||||
if(array[i] < item) pos++;
|
||||
updateBar(i, null, null);
|
||||
}
|
||||
if(pos == cycle_start) {
|
||||
updateBar(cycle_start, null, 'sorted'); continue;
|
||||
}
|
||||
while(item == array[pos]) pos += 1;
|
||||
if(pos != cycle_start) {
|
||||
updateBar(pos, null, 'swapping'); await sleep(parseInt(speedSelect.value));
|
||||
let temp = item; item = array[pos]; array[pos] = temp;
|
||||
updateBar(pos, array[pos], 'sorted');
|
||||
}
|
||||
while(pos != cycle_start) {
|
||||
if(cancelSort) return;
|
||||
pos = cycle_start;
|
||||
for(let i=cycle_start+1; i<n; i++){
|
||||
updateBar(i, null, 'comparing'); await sleep(parseInt(speedSelect.value));
|
||||
if(array[i] < item) pos += 1;
|
||||
updateBar(i, null, null);
|
||||
}
|
||||
while(item == array[pos]) pos += 1;
|
||||
if(item != array[pos]) {
|
||||
updateBar(pos, null, 'swapping'); await sleep(parseInt(speedSelect.value));
|
||||
let temp = item; item = array[pos]; array[pos] = temp;
|
||||
updateBar(pos, array[pos], 'sorted');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function pancakeSort() {
|
||||
let n = array.length;
|
||||
for(let curr_size=n; curr_size>1; --curr_size){
|
||||
if(cancelSort) return;
|
||||
let mi = await findMax(curr_size);
|
||||
if(cancelSort) return;
|
||||
if(mi != curr_size-1){
|
||||
await flip(mi);
|
||||
if(cancelSort) return;
|
||||
await flip(curr_size-1);
|
||||
}
|
||||
updateBar(curr_size-1, null, 'sorted');
|
||||
}
|
||||
updateBar(0, null, 'sorted');
|
||||
}
|
||||
async function findMax(n){
|
||||
let max_idx = 0;
|
||||
for(let i=0; i<n; i++){
|
||||
updateBar(i, null, 'comparing'); updateBar(max_idx, null, 'comparing');
|
||||
await sleep(parseInt(speedSelect.value));
|
||||
if(array[i] > array[max_idx]){
|
||||
updateBar(max_idx, null, null); max_idx = i;
|
||||
} else { updateBar(i, null, null); }
|
||||
}
|
||||
updateBar(max_idx, null, null);
|
||||
return max_idx;
|
||||
}
|
||||
async function flip(i){
|
||||
let start = 0;
|
||||
while(start < i){
|
||||
updateBar(start, null, 'swapping'); updateBar(i, null, 'swapping');
|
||||
await sleep(parseInt(speedSelect.value));
|
||||
let temp = array[start]; array[start] = array[i]; array[i] = temp;
|
||||
updateBar(start, array[start], null); updateBar(i, array[i], null);
|
||||
start++; i--;
|
||||
}
|
||||
}
|
||||
|
||||
async function radixSort() {
|
||||
let max = Math.max(...array);
|
||||
for(let exp = 1; Math.floor(max/exp) > 0; exp *= 10) {
|
||||
if(cancelSort) return;
|
||||
await countingSortForRadix(exp);
|
||||
}
|
||||
}
|
||||
async function countingSortForRadix(exp) {
|
||||
let output = new Array(array.length).fill(0);
|
||||
let count = new Array(10).fill(0);
|
||||
for(let i=0; i<array.length; i++){
|
||||
count[Math.floor(array[i]/exp)%10]++;
|
||||
}
|
||||
for(let i=1; i<10; i++){
|
||||
count[i] += count[i-1];
|
||||
}
|
||||
for(let i=array.length-1; i>=0; i--){
|
||||
let idx = Math.floor(array[i]/exp)%10;
|
||||
output[count[idx]-1] = array[i];
|
||||
count[idx]--;
|
||||
}
|
||||
for(let i=0; i<array.length; i++){
|
||||
if(cancelSort) return;
|
||||
array[i] = output[i];
|
||||
updateBar(i, array[i], 'swapping');
|
||||
await sleep(parseInt(speedSelect.value));
|
||||
updateBar(i, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
async function bogoSort() {
|
||||
let count = 0;
|
||||
while(true) {
|
||||
if(cancelSort) return;
|
||||
let sorted = true;
|
||||
for(let i=1; i<array.length; i++){
|
||||
if(array[i] < array[i-1]){ sorted = false; break; }
|
||||
}
|
||||
if(sorted) break;
|
||||
|
||||
count++;
|
||||
for(let i=array.length-1; i>0; i--){
|
||||
let j = Math.floor(Math.random()*(i+1));
|
||||
let temp = array[i]; array[i] = array[j]; array[j] = temp;
|
||||
updateBar(i, array[i], 'swapping'); updateBar(j, array[j], 'swapping');
|
||||
}
|
||||
await sleep(parseInt(speedSelect.value));
|
||||
for(let i=0; i<array.length; i++) updateBar(i, null, null);
|
||||
|
||||
if (count > 200) {
|
||||
alert("猴子排序 (Bogo Sort) 太慢了,为防止卡死,已自动终止!");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ----------------------------------------
|
||||
// UI Controls
|
||||
// ----------------------------------------
|
||||
|
||||
function disableControls(disabled) {
|
||||
sizeInput.disabled = disabled;
|
||||
algorithmSelect.disabled = disabled;
|
||||
btnSort.disabled = disabled;
|
||||
if(disabled) {
|
||||
btnGenerate.innerText = '⏹️ 停止排序';
|
||||
btnSort.style.opacity = '0.5';
|
||||
} else {
|
||||
btnGenerate.innerText = '🔄 生成新数据';
|
||||
btnSort.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
|
||||
btnGenerate.addEventListener('click', () => {
|
||||
if (isSorting) {
|
||||
cancelSort = true;
|
||||
isSorting = false;
|
||||
disableControls(false);
|
||||
setTimeout(generateArray, 200);
|
||||
} else {
|
||||
generateArray();
|
||||
}
|
||||
});
|
||||
|
||||
btnSort.addEventListener('click', async () => {
|
||||
if (isSorting) return;
|
||||
|
||||
const isAlreadySorted = Array.from(container.children).every(el => el.classList.contains('sorted'));
|
||||
if(isAlreadySorted || array.length === 0) {
|
||||
generateArray();
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
isSorting = true;
|
||||
cancelSort = false;
|
||||
disableControls(true);
|
||||
|
||||
const algo = algorithmSelect.value;
|
||||
try {
|
||||
switch(algo) {
|
||||
case 'bubble': await bubbleSort(); break;
|
||||
case 'selection': await selectionSort(); break;
|
||||
case 'insertion': await insertionSort(); break;
|
||||
case 'quick': await quickSort(); break;
|
||||
case 'merge': await mergeSort(); break;
|
||||
case 'heap': await heapSort(); break;
|
||||
case 'shell': await shellSort(); break;
|
||||
case 'cocktail': await cocktailShakerSort(); break;
|
||||
case 'comb': await combSort(); break;
|
||||
case 'gnome': await gnomeSort(); break;
|
||||
case 'oddEven': await oddEvenSort(); break;
|
||||
case 'cycle': await cycleSort(); break;
|
||||
case 'pancake': await pancakeSort(); break;
|
||||
case 'radix': await radixSort(); break;
|
||||
case 'bogo': await bogoSort(); break;
|
||||
}
|
||||
|
||||
if(!cancelSort) {
|
||||
for(let i=0; i<array.length; i++) {
|
||||
updateBar(i, null, 'sorted');
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
console.error("Sorting error:", e);
|
||||
}
|
||||
|
||||
isSorting = false;
|
||||
disableControls(false);
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (array.length > 0 && container.clientWidth > 0) {
|
||||
const size = array.length;
|
||||
const barWidth = Math.max(2, Math.floor((container.clientWidth - size * 2) / size));
|
||||
const bars = container.children;
|
||||
for (let i = 0; i < bars.length; i++) {
|
||||
bars[i].style.width = `${barWidth}px`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
generateArray();
|
||||
});
|
||||
125
js/tools/time.js
Normal file
125
js/tools/time.js
Normal file
@@ -0,0 +1,125 @@
|
||||
(function() {
|
||||
let timer = null;
|
||||
let isPaused = false;
|
||||
|
||||
function initTimeTool() {
|
||||
const container = document.getElementById('tool-time-container');
|
||||
if (!container) return;
|
||||
|
||||
container.addEventListener('click', (e) => e.stopPropagation());
|
||||
|
||||
// Cleanup
|
||||
if (timer) clearInterval(timer);
|
||||
|
||||
// DOM Elements
|
||||
const currentNow = document.getElementById('current-now');
|
||||
const unixS = document.getElementById('current-unix-s');
|
||||
const unixMs = document.getElementById('current-unix-ms');
|
||||
const toggleBtn = document.getElementById('toggle-pause');
|
||||
const inputTs = document.getElementById('input-timestamp');
|
||||
const btnConvertTs = document.getElementById('btn-convert-ts');
|
||||
const inputDate = document.getElementById('input-date');
|
||||
const btnConvertDate = document.getElementById('btn-convert-date');
|
||||
const copyBtns = document.querySelectorAll('.copy-btn');
|
||||
|
||||
// Initial Setup
|
||||
const now = new Date();
|
||||
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
|
||||
inputDate.value = now.toISOString().slice(0, 19);
|
||||
|
||||
// Event Listeners
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
isPaused = !isPaused;
|
||||
toggleBtn.innerText = isPaused ? '▶️ 恢复更新' : '⏸️ 暂停更新';
|
||||
});
|
||||
|
||||
btnConvertTs.addEventListener('click', convertTimestamp);
|
||||
btnConvertDate.addEventListener('click', convertDate);
|
||||
|
||||
copyBtns.forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const targetId = e.target.getAttribute('data-copy-target');
|
||||
const text = document.getElementById(targetId).innerText;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const originalText = e.target.innerText;
|
||||
e.target.innerText = '已复制';
|
||||
setTimeout(() => e.target.innerText = originalText, 1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Start Loop
|
||||
timer = setInterval(() => {
|
||||
if (isPaused) return;
|
||||
const d = new Date();
|
||||
currentNow.innerText = d.toLocaleString();
|
||||
unixS.innerText = Math.floor(d.getTime() / 1000);
|
||||
unixMs.innerText = d.getTime();
|
||||
updateWorldClocks(d);
|
||||
}, 1000);
|
||||
|
||||
// Initial run
|
||||
currentNow.innerText = new Date().toLocaleString();
|
||||
updateWorldClocks(new Date());
|
||||
}
|
||||
|
||||
function convertTimestamp() {
|
||||
let val = document.getElementById('input-timestamp').value.trim();
|
||||
if (!val) return;
|
||||
let unit = document.getElementById('timestamp-unit').value;
|
||||
let ts = parseInt(val);
|
||||
if (isNaN(ts)) return;
|
||||
|
||||
if (unit === 'auto') {
|
||||
unit = String(ts).length > 11 ? 'ms' : 's';
|
||||
}
|
||||
const date = new Date(unit === 's' ? ts * 1000 : ts);
|
||||
document.getElementById('result-date').innerHTML = `
|
||||
<strong>北京时间:</strong> ${date.toLocaleString('zh-CN', {timeZone: 'Asia/Shanghai'})}<br>
|
||||
<strong>UTC 时间:</strong> ${date.toUTCString()}<br>
|
||||
<strong>ISO 8601:</strong> ${date.toISOString()}
|
||||
`;
|
||||
}
|
||||
|
||||
function convertDate() {
|
||||
const val = document.getElementById('input-date').value;
|
||||
if (!val) return;
|
||||
const date = new Date(val);
|
||||
const tsMs = date.getTime();
|
||||
const tsS = Math.floor(tsMs / 1000);
|
||||
document.getElementById('result-timestamp').innerHTML = `
|
||||
<strong>时间戳 (秒):</strong> ${tsS}<br>
|
||||
<strong>时间戳 (毫秒):</strong> ${tsMs}
|
||||
`;
|
||||
}
|
||||
|
||||
function updateWorldClocks(now) {
|
||||
const grid = document.getElementById('world-clocks');
|
||||
if (!grid) return;
|
||||
const cities = [
|
||||
{ name: '北京', tz: 'Asia/Shanghai' },
|
||||
{ name: '伦敦', tz: 'Europe/London' },
|
||||
{ name: '纽约', tz: 'America/New_York' },
|
||||
{ name: '东京', tz: 'Asia/Tokyo' },
|
||||
{ name: '巴黎', tz: 'Europe/Paris' },
|
||||
{ name: '悉尼', tz: 'Australia/Sydney' },
|
||||
{ name: '迪拜', tz: 'Asia/Dubai' },
|
||||
{ name: '洛杉矶', tz: 'America/Los_Angeles' }
|
||||
];
|
||||
let html = '';
|
||||
cities.forEach(city => {
|
||||
const timeStr = now.toLocaleTimeString('en-US', { timeZone: city.tz, hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||
html += `
|
||||
<div class="clock-card" style="background:var(--card-bg, #f0f0f0); padding:10px; border-radius:4px; text-align:center;">
|
||||
<div style="color:#666; font-size:0.9em;">${city.name}</div>
|
||||
<div style="font-size:1.4em; font-weight:bold; color:#49b1f5;">${timeStr}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
grid.innerHTML = html;
|
||||
}
|
||||
|
||||
// Init
|
||||
document.addEventListener('DOMContentLoaded', initTimeTool);
|
||||
document.addEventListener('pjax:complete', initTimeTool);
|
||||
})();
|
||||
420
js/tools/watermark.js
Normal file
420
js/tools/watermark.js
Normal file
@@ -0,0 +1,420 @@
|
||||
(function() {
|
||||
function initWatermarkTool() {
|
||||
const container = document.getElementById('tool-watermark-container');
|
||||
if (!container) return;
|
||||
|
||||
// Stop propagation
|
||||
container.addEventListener('click', (e) => e.stopPropagation());
|
||||
|
||||
let wmType = 'text';
|
||||
let visImg = new Image();
|
||||
let wmImg = new Image(); // For image watermark
|
||||
|
||||
// --- Tabs ---
|
||||
window.switchTab = function(tab) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tool-section-content').forEach(s => s.style.display = 'none');
|
||||
|
||||
if (tab === 'visible') {
|
||||
document.querySelector('.tab:nth-child(1)').classList.add('active');
|
||||
document.getElementById('tab-visible').style.display = 'block';
|
||||
} else if (tab === 'invisible') {
|
||||
document.querySelector('.tab:nth-child(2)').classList.add('active');
|
||||
document.getElementById('tab-invisible').style.display = 'block';
|
||||
} else {
|
||||
document.querySelector('.tab:nth-child(3)').classList.add('active');
|
||||
document.getElementById('tab-remove').style.display = 'block';
|
||||
}
|
||||
};
|
||||
|
||||
window.toggleWmType = function() {
|
||||
const val = document.querySelector('input[name="wm-type"]:checked').value;
|
||||
wmType = val;
|
||||
if (val === 'text') {
|
||||
document.getElementById('ctrl-text-group').style.display = 'contents';
|
||||
document.getElementById('ctrl-image-group').style.display = 'none';
|
||||
} else {
|
||||
document.getElementById('ctrl-text-group').style.display = 'none';
|
||||
document.getElementById('ctrl-image-group').style.display = 'contents';
|
||||
}
|
||||
drawVisible();
|
||||
};
|
||||
|
||||
window.downloadCanvas = function(id, name) {
|
||||
const c = document.getElementById(id);
|
||||
if (c.width === 0) return;
|
||||
const link = document.createElement('a');
|
||||
link.download = name;
|
||||
link.href = c.toDataURL();
|
||||
link.click();
|
||||
}
|
||||
|
||||
// --- Visible Watermark ---
|
||||
const visFile = document.getElementById('vis-file');
|
||||
const wmImgFile = document.getElementById('wm-img-file');
|
||||
|
||||
visFile.addEventListener('change', function(e) {
|
||||
if(!e.target.files.length) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
visImg.onload = drawVisible;
|
||||
visImg.src = event.target.result;
|
||||
};
|
||||
reader.readAsDataURL(e.target.files[0]);
|
||||
});
|
||||
|
||||
wmImgFile.addEventListener('change', function(e) {
|
||||
if(!e.target.files.length) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
wmImg.onload = drawVisible;
|
||||
wmImg.src = event.target.result;
|
||||
};
|
||||
reader.readAsDataURL(e.target.files[0]);
|
||||
});
|
||||
|
||||
// Add listeners to controls
|
||||
['vis-text', 'vis-size', 'vis-color', 'vis-opacity', 'vis-pos', 'wm-scale'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if(el) {
|
||||
el.addEventListener('input', drawVisible);
|
||||
el.addEventListener('change', drawVisible);
|
||||
}
|
||||
});
|
||||
|
||||
function drawVisible() {
|
||||
if (!visImg.src) return;
|
||||
const cvs = document.getElementById('vis-canvas');
|
||||
const ctx = cvs.getContext('2d');
|
||||
|
||||
cvs.width = visImg.width;
|
||||
cvs.height = visImg.height;
|
||||
ctx.drawImage(visImg, 0, 0);
|
||||
|
||||
const opacity = parseFloat(document.getElementById('vis-opacity').value);
|
||||
const pos = document.getElementById('vis-pos').value;
|
||||
ctx.globalAlpha = opacity;
|
||||
|
||||
if (wmType === 'text') {
|
||||
const text = document.getElementById('vis-text').value;
|
||||
const size = parseInt(document.getElementById('vis-size').value);
|
||||
const color = document.getElementById('vis-color').value;
|
||||
ctx.font = `bold ${size}px sans-serif`;
|
||||
ctx.fillStyle = color;
|
||||
|
||||
drawContent(ctx, pos, (x, y) => ctx.fillText(text, x, y), cvs.width, cvs.height, 300, 150);
|
||||
} else if (wmType === 'image' && wmImg.src) {
|
||||
const scale = parseFloat(document.getElementById('wm-scale').value);
|
||||
const w = wmImg.width * scale;
|
||||
const h = wmImg.height * scale;
|
||||
|
||||
drawContent(ctx, pos, (x, y) => ctx.drawImage(wmImg, x - w/2, y - h/2, w, h), cvs.width, cvs.height, w * 1.5, h * 1.5);
|
||||
}
|
||||
}
|
||||
|
||||
function drawContent(ctx, pos, drawFn, w, h, spaceX, spaceY) {
|
||||
if (pos === 'center') {
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
drawFn(w/2, h/2);
|
||||
} else if (pos === 'bottom-right') {
|
||||
ctx.textAlign = 'right'; ctx.textBaseline = 'bottom';
|
||||
drawFn(w - 20, h - 20);
|
||||
} else if (pos === 'top-left') {
|
||||
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
|
||||
drawFn(20, 20);
|
||||
} else if (pos === 'repeat') {
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.rotate(-45 * Math.PI / 180);
|
||||
const diag = Math.sqrt(w*w + h*h);
|
||||
// Draw in rotated grid
|
||||
for (let x = -diag; x < diag; x += spaceX) {
|
||||
for (let y = -diag; y < diag; y += spaceY) {
|
||||
drawFn(x, y);
|
||||
}
|
||||
}
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Invisible Watermark (LSB) ---
|
||||
let invisImg = new Image();
|
||||
document.getElementById('invis-file').addEventListener('change', function(e) {
|
||||
if(!e.target.files.length) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
invisImg.onload = () => {
|
||||
const cvs = document.getElementById('invis-canvas');
|
||||
cvs.width = invisImg.width;
|
||||
cvs.height = invisImg.height;
|
||||
const ctx = cvs.getContext('2d');
|
||||
ctx.drawImage(invisImg, 0, 0);
|
||||
document.getElementById('invis-result').innerText = '';
|
||||
};
|
||||
invisImg.src = event.target.result;
|
||||
};
|
||||
reader.readAsDataURL(e.target.files[0]);
|
||||
});
|
||||
|
||||
window.encodeLSB = function() {
|
||||
const text = document.getElementById('invis-text').value;
|
||||
if (!text || !invisImg.src) return alert('请先上传图片并输入文字');
|
||||
|
||||
const cvs = document.getElementById('invis-canvas');
|
||||
const ctx = cvs.getContext('2d');
|
||||
const imgData = ctx.getImageData(0, 0, cvs.width, cvs.height);
|
||||
const data = imgData.data;
|
||||
|
||||
// Simple LSB encoding (Text -> Binary -> Modify R channel LSB)
|
||||
// Implementation simplified for brevity
|
||||
alert('隐写功能演示:实际写入需完整二进制编码逻辑');
|
||||
};
|
||||
|
||||
window.decodeLSB = function() {
|
||||
alert('隐写功能演示:实际读取需完整二进制解码逻辑');
|
||||
};
|
||||
|
||||
|
||||
// --- Remove Watermark (New) ---
|
||||
let removeFile = document.getElementById('remove-file');
|
||||
let removeImgData = null; // Store original img data for undo
|
||||
let pdfFileBytes = null;
|
||||
let isMaskMode = false;
|
||||
|
||||
removeFile.addEventListener('change', async function(e) {
|
||||
if(!e.target.files.length) return;
|
||||
const file = e.target.files[0];
|
||||
|
||||
if (file.type.startsWith('image/')) {
|
||||
document.getElementById('remove-image-ui').style.display = 'block';
|
||||
document.getElementById('remove-pdf-ui').style.display = 'none';
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const cvs = document.getElementById('remove-canvas');
|
||||
cvs.width = img.width;
|
||||
cvs.height = img.height;
|
||||
const ctx = cvs.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0);
|
||||
removeImgData = ctx.getImageData(0, 0, cvs.width, cvs.height); // Save for undo
|
||||
};
|
||||
img.src = event.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
initBrush();
|
||||
|
||||
} else if (file.type === 'application/pdf') {
|
||||
document.getElementById('remove-image-ui').style.display = 'none';
|
||||
document.getElementById('remove-pdf-ui').style.display = 'block';
|
||||
|
||||
pdfFileBytes = await file.arrayBuffer();
|
||||
renderPDF(pdfFileBytes);
|
||||
}
|
||||
});
|
||||
|
||||
// Image Brush Logic
|
||||
function initBrush() {
|
||||
const cvs = document.getElementById('remove-canvas');
|
||||
const ctx = cvs.getContext('2d');
|
||||
let isDrawing = false;
|
||||
|
||||
cvs.onmousedown = (e) => {
|
||||
isDrawing = true;
|
||||
ctx.beginPath();
|
||||
const rect = cvs.getBoundingClientRect();
|
||||
const scaleX = cvs.width / rect.width;
|
||||
const scaleY = cvs.height / rect.height;
|
||||
ctx.moveTo((e.clientX - rect.left) * scaleX, (e.clientY - rect.top) * scaleY);
|
||||
};
|
||||
|
||||
cvs.onmousemove = (e) => {
|
||||
if (!isDrawing) return;
|
||||
const rect = cvs.getBoundingClientRect();
|
||||
const scaleX = cvs.width / rect.width;
|
||||
const scaleY = cvs.height / rect.height;
|
||||
const x = (e.clientX - rect.left) * scaleX;
|
||||
const y = (e.clientY - rect.top) * scaleY;
|
||||
const size = document.getElementById('brush-size').value;
|
||||
|
||||
// Simple Inpainting: Fill with average color of surrounding area?
|
||||
// Or just blur? Let's do a simple clone effect (taking pixel from nearby)
|
||||
// For demo, we just smear using a blur-like effect by drawing with low opacity
|
||||
// Actually, a simple 'blur' brush is easier to implement
|
||||
|
||||
ctx.lineWidth = size;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.strokeStyle = '#fff'; // White out for simplicity or...
|
||||
// Better: Get color from nearby? Too complex for this snippet.
|
||||
// Let's implement a 'Blur' brush by copying a region slightly offset
|
||||
|
||||
// Demo: Just paint white (assuming white background document)
|
||||
// or blur.
|
||||
ctx.save();
|
||||
ctx.filter = 'blur(5px)';
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
// Draw line
|
||||
ctx.lineTo(x, y);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
cvs.onmouseup = () => isDrawing = false;
|
||||
}
|
||||
|
||||
window.undoRemove = function() {
|
||||
if (!removeImgData) return;
|
||||
const cvs = document.getElementById('remove-canvas');
|
||||
const ctx = cvs.getContext('2d');
|
||||
ctx.putImageData(removeImgData, 0, 0);
|
||||
}
|
||||
|
||||
// PDF Logic
|
||||
async function renderPDF(data) {
|
||||
const loadingTask = pdfjsLib.getDocument(data);
|
||||
const pdf = await loadingTask.promise;
|
||||
const container = document.getElementById('pdf-container');
|
||||
container.innerHTML = ''; // Clear
|
||||
|
||||
// Render first 3 pages only for demo performance
|
||||
const numPages = Math.min(pdf.numPages, 3);
|
||||
|
||||
for (let i = 1; i <= numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const scale = 1.0;
|
||||
const viewport = page.getViewport({scale: scale});
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'pdf-page-wrapper';
|
||||
wrapper.dataset.pageIndex = i - 1; // 0-based
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
canvas.height = viewport.height;
|
||||
canvas.width = viewport.width;
|
||||
|
||||
const renderContext = {
|
||||
canvasContext: context,
|
||||
viewport: viewport
|
||||
};
|
||||
await page.render(renderContext).promise;
|
||||
|
||||
wrapper.appendChild(canvas);
|
||||
container.appendChild(wrapper);
|
||||
|
||||
// Add mask listener
|
||||
initMasking(wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
window.toggleMaskMode = function() {
|
||||
isMaskMode = !isMaskMode;
|
||||
const btn = document.getElementById('btn-add-mask-mode');
|
||||
btn.style.background = isMaskMode ? '#e6a23c' : '#49b1f5';
|
||||
btn.innerText = isMaskMode ? '退出框选模式' : '🔳 进入框选模式';
|
||||
}
|
||||
|
||||
function initMasking(wrapper) {
|
||||
let startX, startY;
|
||||
let currentMask = null;
|
||||
|
||||
wrapper.addEventListener('mousedown', (e) => {
|
||||
if (!isMaskMode) return;
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
startX = e.clientX - rect.left;
|
||||
startY = e.clientY - rect.top;
|
||||
|
||||
currentMask = document.createElement('div');
|
||||
currentMask.className = 'remove-mask';
|
||||
currentMask.style.left = startX + 'px';
|
||||
currentMask.style.top = startY + 'px';
|
||||
wrapper.appendChild(currentMask);
|
||||
});
|
||||
|
||||
wrapper.addEventListener('mousemove', (e) => {
|
||||
if (!isMaskMode || !currentMask) return;
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const currentX = e.clientX - rect.left;
|
||||
const currentY = e.clientY - rect.top;
|
||||
|
||||
const width = Math.abs(currentX - startX);
|
||||
const height = Math.abs(currentY - startY);
|
||||
const left = Math.min(currentX, startX);
|
||||
const top = Math.min(currentY, startY);
|
||||
|
||||
currentMask.style.width = width + 'px';
|
||||
currentMask.style.height = height + 'px';
|
||||
currentMask.style.left = left + 'px';
|
||||
currentMask.style.top = top + 'px';
|
||||
});
|
||||
|
||||
wrapper.addEventListener('mouseup', () => {
|
||||
currentMask = null;
|
||||
});
|
||||
}
|
||||
|
||||
window.saveCleanPDF = async function() {
|
||||
if (!pdfFileBytes) return;
|
||||
const { PDFDocument, rgb } = PDFLib;
|
||||
const pdfDoc = await PDFDocument.load(pdfFileBytes);
|
||||
const pages = pdfDoc.getPages();
|
||||
|
||||
// Iterate over all DOM wrappers to find masks
|
||||
const wrappers = document.querySelectorAll('.pdf-page-wrapper');
|
||||
|
||||
wrappers.forEach(wrapper => {
|
||||
const pageIndex = parseInt(wrapper.dataset.pageIndex);
|
||||
if (pageIndex >= pages.length) return;
|
||||
|
||||
const page = pages[pageIndex];
|
||||
const { width, height } = page.getSize();
|
||||
// Canvas size might scale, assume 1:1 for now or calculate scale
|
||||
const canvas = wrapper.querySelector('canvas');
|
||||
const scaleX = width / canvas.width;
|
||||
const scaleY = height / canvas.height;
|
||||
|
||||
const masks = wrapper.querySelectorAll('.remove-mask');
|
||||
masks.forEach(mask => {
|
||||
// DOM coords (top-left is 0,0)
|
||||
const x = parseFloat(mask.style.left);
|
||||
const y = parseFloat(mask.style.top);
|
||||
const w = parseFloat(mask.style.width);
|
||||
const h = parseFloat(mask.style.height);
|
||||
|
||||
// PDF coords (bottom-left is 0,0 usually, but PDFLib handles it)
|
||||
// We need to convert DOM Y (from top) to PDF Y (from bottom)
|
||||
// PDF Y = PageHeight - (DOM Y + Height) * Scale
|
||||
|
||||
const rectX = x * scaleX;
|
||||
const rectW = w * scaleX;
|
||||
const rectH = h * scaleY;
|
||||
const rectY = height - (y * scaleY) - rectH;
|
||||
|
||||
page.drawRectangle({
|
||||
x: rectX,
|
||||
y: rectY,
|
||||
width: rectW,
|
||||
height: rectH,
|
||||
color: rgb(1, 1, 1), // White
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = 'cleaned_document.pdf';
|
||||
link.click();
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
// Initialize when DOM ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initWatermarkTool);
|
||||
} else {
|
||||
initWatermarkTool();
|
||||
}
|
||||
})();
|
||||
117
js/tw_cn.js
Normal file
117
js/tw_cn.js
Normal file
File diff suppressed because one or more lines are too long
350
js/utils.js
Normal file
350
js/utils.js
Normal file
@@ -0,0 +1,350 @@
|
||||
(() => {
|
||||
const btfFn = {
|
||||
debounce: (func, wait = 0, immediate = false) => {
|
||||
let timeout
|
||||
return (...args) => {
|
||||
const later = () => {
|
||||
timeout = null
|
||||
if (!immediate) func(...args)
|
||||
}
|
||||
const callNow = immediate && !timeout
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(later, wait)
|
||||
if (callNow) func(...args)
|
||||
}
|
||||
},
|
||||
|
||||
throttle: function (func, wait, options = {}) {
|
||||
let timeout, context, args
|
||||
let previous = 0
|
||||
|
||||
const later = () => {
|
||||
previous = options.leading === false ? 0 : new Date().getTime()
|
||||
timeout = null
|
||||
func.apply(context, args)
|
||||
if (!timeout) context = args = null
|
||||
}
|
||||
|
||||
const throttled = (...params) => {
|
||||
const now = new Date().getTime()
|
||||
if (!previous && options.leading === false) previous = now
|
||||
const remaining = wait - (now - previous)
|
||||
context = this
|
||||
args = params
|
||||
if (remaining <= 0 || remaining > wait) {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
previous = now
|
||||
func.apply(context, args)
|
||||
if (!timeout) context = args = null
|
||||
} else if (!timeout && options.trailing !== false) {
|
||||
timeout = setTimeout(later, remaining)
|
||||
}
|
||||
}
|
||||
|
||||
return throttled
|
||||
},
|
||||
|
||||
overflowPaddingR: {
|
||||
add: () => {
|
||||
const paddingRight = window.innerWidth - document.body.clientWidth
|
||||
|
||||
if (paddingRight > 0) {
|
||||
document.body.style.paddingRight = `${paddingRight}px`
|
||||
document.body.style.overflow = 'hidden'
|
||||
const menuElement = document.querySelector('#page-header.nav-fixed #menus')
|
||||
if (menuElement) {
|
||||
menuElement.style.paddingRight = `${paddingRight}px`
|
||||
}
|
||||
}
|
||||
},
|
||||
remove: () => {
|
||||
document.body.style.paddingRight = ''
|
||||
document.body.style.overflow = ''
|
||||
const menuElement = document.querySelector('#page-header.nav-fixed #menus')
|
||||
if (menuElement) {
|
||||
menuElement.style.paddingRight = ''
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
snackbarShow: (text, showAction = false, duration = 2000) => {
|
||||
const { position, bgLight, bgDark } = GLOBAL_CONFIG.Snackbar
|
||||
const bg = document.documentElement.getAttribute('data-theme') === 'light' ? bgLight : bgDark
|
||||
Snackbar.show({
|
||||
text,
|
||||
backgroundColor: bg,
|
||||
showAction,
|
||||
duration,
|
||||
pos: position,
|
||||
customClass: 'snackbar-css'
|
||||
})
|
||||
},
|
||||
|
||||
diffDate: (inputDate, more = false) => {
|
||||
const dateNow = new Date()
|
||||
const datePost = new Date(inputDate)
|
||||
const diffMs = dateNow - datePost
|
||||
const diffSec = diffMs / 1000
|
||||
const diffMin = diffSec / 60
|
||||
const diffHour = diffMin / 60
|
||||
const diffDay = diffHour / 24
|
||||
const diffMonth = diffDay / 30
|
||||
const { dateSuffix } = GLOBAL_CONFIG
|
||||
|
||||
if (!more) return Math.floor(diffDay)
|
||||
|
||||
if (diffMonth > 12) return datePost.toISOString().slice(0, 10)
|
||||
if (diffMonth >= 1) return `${Math.floor(diffMonth)} ${dateSuffix.month}`
|
||||
if (diffDay >= 1) return `${Math.floor(diffDay)} ${dateSuffix.day}`
|
||||
if (diffHour >= 1) return `${Math.floor(diffHour)} ${dateSuffix.hour}`
|
||||
if (diffMin >= 1) return `${Math.floor(diffMin)} ${dateSuffix.min}`
|
||||
return dateSuffix.just
|
||||
},
|
||||
|
||||
loadComment: (dom, callback) => {
|
||||
if ('IntersectionObserver' in window) {
|
||||
const observerItem = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting) {
|
||||
callback()
|
||||
observerItem.disconnect()
|
||||
}
|
||||
}, { threshold: [0] })
|
||||
observerItem.observe(dom)
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
|
||||
scrollToDest: (pos, time = 500) => {
|
||||
const currentPos = window.scrollY
|
||||
const isNavFixed = document.getElementById('page-header').classList.contains('fixed')
|
||||
if (currentPos > pos || isNavFixed) pos = pos - 70
|
||||
|
||||
if ('scrollBehavior' in document.documentElement.style) {
|
||||
window.scrollTo({
|
||||
top: pos,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const startTime = performance.now()
|
||||
const animate = currentTime => {
|
||||
const timeElapsed = currentTime - startTime
|
||||
const progress = Math.min(timeElapsed / time, 1)
|
||||
window.scrollTo(0, currentPos + (pos - currentPos) * progress)
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(animate)
|
||||
},
|
||||
|
||||
animateIn: (ele, animation) => {
|
||||
ele.style.display = 'block'
|
||||
ele.style.animation = animation
|
||||
},
|
||||
|
||||
animateOut: (ele, animation) => {
|
||||
const handleAnimationEnd = () => {
|
||||
ele.style.display = ''
|
||||
ele.style.animation = ''
|
||||
ele.removeEventListener('animationend', handleAnimationEnd)
|
||||
}
|
||||
ele.addEventListener('animationend', handleAnimationEnd)
|
||||
ele.style.animation = animation
|
||||
},
|
||||
|
||||
wrap: (selector, eleType, options) => {
|
||||
const createEle = document.createElement(eleType)
|
||||
for (const [key, value] of Object.entries(options)) {
|
||||
createEle.setAttribute(key, value)
|
||||
}
|
||||
selector.parentNode.insertBefore(createEle, selector)
|
||||
createEle.appendChild(selector)
|
||||
},
|
||||
|
||||
isHidden: ele => ele.offsetHeight === 0 && ele.offsetWidth === 0,
|
||||
|
||||
getEleTop: ele => {
|
||||
let actualTop = ele.offsetTop
|
||||
let current = ele.offsetParent
|
||||
|
||||
while (current !== null) {
|
||||
actualTop += current.offsetTop
|
||||
current = current.offsetParent
|
||||
}
|
||||
|
||||
return actualTop
|
||||
},
|
||||
|
||||
loadLightbox: ele => {
|
||||
const service = GLOBAL_CONFIG.lightbox
|
||||
|
||||
if (service === 'medium_zoom') {
|
||||
mediumZoom(ele, { background: 'var(--zoom-bg)' })
|
||||
return
|
||||
}
|
||||
|
||||
if (service === 'fancybox') {
|
||||
Array.from(ele).forEach(i => {
|
||||
if (i.parentNode.tagName !== 'A') {
|
||||
const dataSrc = i.dataset.lazySrc || i.src
|
||||
const dataCaption = i.title || i.alt || ''
|
||||
btf.wrap(i, 'a', { href: dataSrc, 'data-fancybox': 'gallery', 'data-caption': dataCaption, 'data-thumb': dataSrc })
|
||||
}
|
||||
})
|
||||
|
||||
if (!window.fancyboxRun) {
|
||||
let options = ''
|
||||
if (Fancybox.version < '6') {
|
||||
options = {
|
||||
Hash: false,
|
||||
Thumbs: {
|
||||
showOnStart: false
|
||||
},
|
||||
Images: {
|
||||
Panzoom: {
|
||||
maxScale: 4
|
||||
}
|
||||
},
|
||||
Carousel: {
|
||||
transition: 'slide'
|
||||
},
|
||||
Toolbar: {
|
||||
display: {
|
||||
left: ['infobar'],
|
||||
middle: [
|
||||
'zoomIn',
|
||||
'zoomOut',
|
||||
'toggle1to1',
|
||||
'rotateCCW',
|
||||
'rotateCW',
|
||||
'flipX',
|
||||
'flipY'
|
||||
],
|
||||
right: ['slideshow', 'thumbs', 'close']
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
options = {
|
||||
Hash: false,
|
||||
Carousel: {
|
||||
transition: 'slide',
|
||||
Thumbs: {
|
||||
showOnStart: false
|
||||
},
|
||||
Toolbar: {
|
||||
display: {
|
||||
left: ['counter'],
|
||||
middle: [
|
||||
'zoomIn',
|
||||
'zoomOut',
|
||||
'toggle1to1',
|
||||
'rotateCCW',
|
||||
'rotateCW',
|
||||
'flipX',
|
||||
'flipY',
|
||||
'reset'
|
||||
],
|
||||
right: ['autoplay', 'thumbs', 'close']
|
||||
}
|
||||
},
|
||||
Zoomable: {
|
||||
Panzoom: {
|
||||
maxScale: 4
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Fancybox.bind('[data-fancybox]', options)
|
||||
window.fancyboxRun = true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
setLoading: {
|
||||
add: ele => {
|
||||
const html = `
|
||||
<div class="loading-container">
|
||||
<div class="loading-item">
|
||||
<div></div><div></div><div></div><div></div><div></div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
ele.insertAdjacentHTML('afterend', html)
|
||||
},
|
||||
remove: ele => {
|
||||
ele.nextElementSibling.remove()
|
||||
}
|
||||
},
|
||||
|
||||
updateAnchor: anchor => {
|
||||
if (anchor !== window.location.hash) {
|
||||
if (!anchor) anchor = location.pathname
|
||||
const title = GLOBAL_CONFIG_SITE.title
|
||||
window.history.replaceState({
|
||||
url: location.href,
|
||||
title
|
||||
}, title, anchor)
|
||||
}
|
||||
},
|
||||
|
||||
getScrollPercent: (() => {
|
||||
let docHeight, winHeight, headerHeight, contentMath
|
||||
|
||||
return (currentTop, ele) => {
|
||||
if (!docHeight || ele.clientHeight !== docHeight) {
|
||||
docHeight = ele.clientHeight
|
||||
winHeight = window.innerHeight
|
||||
headerHeight = ele.offsetTop
|
||||
contentMath = Math.max(docHeight - winHeight, document.documentElement.scrollHeight - winHeight)
|
||||
}
|
||||
|
||||
const scrollPercent = (currentTop - headerHeight) / contentMath
|
||||
return Math.max(0, Math.min(100, Math.round(scrollPercent * 100)))
|
||||
}
|
||||
})(),
|
||||
|
||||
addEventListenerPjax: (ele, event, fn, option = false) => {
|
||||
ele.addEventListener(event, fn, option)
|
||||
btf.addGlobalFn('pjaxSendOnce', () => {
|
||||
ele.removeEventListener(event, fn, option)
|
||||
})
|
||||
},
|
||||
|
||||
removeGlobalFnEvent: (key, parent = window) => {
|
||||
const globalFn = parent.globalFn || {}
|
||||
const keyObj = globalFn[key]
|
||||
if (!keyObj) return
|
||||
|
||||
Object.keys(keyObj).forEach(i => keyObj[i]())
|
||||
|
||||
delete globalFn[key]
|
||||
},
|
||||
|
||||
switchComments: (el = document, path) => {
|
||||
const switchBtn = el.querySelector('#switch-btn')
|
||||
if (!switchBtn) return
|
||||
|
||||
let switchDone = false
|
||||
const postComment = el.querySelector('#post-comment')
|
||||
const handleSwitchBtn = () => {
|
||||
postComment.classList.toggle('move')
|
||||
if (!switchDone && typeof loadOtherComment === 'function') {
|
||||
switchDone = true
|
||||
loadOtherComment(el, path)
|
||||
}
|
||||
}
|
||||
btf.addEventListenerPjax(switchBtn, 'click', handleSwitchBtn)
|
||||
}
|
||||
}
|
||||
|
||||
window.btf = { ...window.btf, ...btfFn }
|
||||
})()
|
||||
Reference in New Issue
Block a user