diff --git a/assets/js/theme.js b/assets/js/theme.js
index c9b55bf5..34312f1f 100644
--- a/assets/js/theme.js
+++ b/assets/js/theme.js
@@ -1,12 +1,26 @@
// TODO use ESLint to check the code style
-import Util from './util';
+import {
+ forEach,
+ getScrollTop,
+ isMobile,
+ isTocStatic,
+ animateCSS,
+ isValidDate,
+ scrollIntoView,
+ getStagingDOM,
+ createCopyText,
+ isObjectLiteral,
+ HTMLEscape,
+} from './utils/common';
import FileTree from './lib/file-tree.js'
+const copyText = createCopyText();
+
class FixIt {
constructor() {
this.config = window.config;
this.isDark = document.documentElement.dataset.theme === 'dark';
- this.newScrollTop = Util.getScrollTop();
+ this.newScrollTop = getScrollTop();
this.oldScrollTop = this.newScrollTop;
this.scrollEventSet = new Set();
this.resizeEventSet = new Set();
@@ -29,7 +43,7 @@ class FixIt {
}
initSVGIcon() {
- Util.forEach(document.querySelectorAll('[data-svg-src]'), ($icon) => {
+ forEach(document.querySelectorAll('[data-svg-src]'), ($icon) => {
fetch($icon.dataset.svgSrc)
.then((response) => response.text())
.then((svg) => {
@@ -58,7 +72,7 @@ class FixIt {
}
initMenuDesktop() {
- Util.forEach(document.querySelectorAll('.has-children'), ($item) => {
+ forEach(document.querySelectorAll('.has-children'), ($item) => {
$item.querySelector('.sub-menu').style.minWidth = `${$item.offsetWidth - 8}px`;
});
}
@@ -78,7 +92,7 @@ class FixIt {
});
this.clickMaskEventSet.add(this._menuMobileOnClickMask);
// add nested menu toggler
- Util.forEach(document.querySelectorAll('.menu-item>.nested-item'), ($nestedItem) => {
+ forEach(document.querySelectorAll('.menu-item>.nested-item'), ($nestedItem) => {
$nestedItem.addEventListener('click', function () {
this.parentNode.querySelector('.sub-menu').classList.toggle('open');
this.querySelector('.dropdown-icon').classList.toggle('open');
@@ -87,7 +101,7 @@ class FixIt {
}
initSwitchTheme() {
- Util.forEach(document.getElementsByClassName('theme-switch'), ($themeSwitch) => {
+ forEach(document.getElementsByClassName('theme-switch'), ($themeSwitch) => {
$themeSwitch.addEventListener('click', () => {
document.documentElement.dataset.theme = this.isDark ? 'light' : 'dark';
document.documentElement.style.setProperty('color-scheme', this.isDark ? 'light' : 'dark');
@@ -134,11 +148,11 @@ class FixIt {
initSearch() {
const searchConfig = this.config.search;
- const isMobile = Util.isMobile();
+ const _isMobile = isMobile();
if (
!searchConfig ||
- (isMobile && this._searchMobileOnce) ||
- (!isMobile && this._searchDesktopOnce)
+ (_isMobile && this._searchMobileOnce) ||
+ (!_isMobile && this._searchDesktopOnce)
)
return;
// Initialize default search config
@@ -154,7 +168,7 @@ class FixIt {
const ignoreLocation = searchConfig.ignoreLocation ?? false;
const useExtendedSearch = searchConfig.useExtendedSearch ?? false;
const ignoreFieldNorm = searchConfig.ignoreFieldNorm ?? false;
- const suffix = isMobile ? 'mobile' : 'desktop';
+ const suffix = _isMobile ? 'mobile' : 'desktop';
const $header = document.getElementById(`header-${suffix}`);
const $searchInput = document.getElementById(`search-input-${suffix}`);
const $searchToggle = document.getElementById(`search-toggle-${suffix}`);
@@ -164,7 +178,7 @@ class FixIt {
// goto the PostChat panel rather than search results
if (searchConfig.type === 'post-chat' && window.postChatUser) {
- if (isMobile) {
+ if (_isMobile) {
$searchInput.addEventListener('focus', () => {
window.postChatUser.setSearchInput('');
}, false);
@@ -176,7 +190,7 @@ class FixIt {
return;
}
- if (isMobile) {
+ if (_isMobile) {
this._searchMobileOnce = true;
$searchInput.addEventListener('focus', () => {
this.disableScrollEvent = true;
@@ -341,7 +355,7 @@ class FixIt {
templates: {
suggestion: ({ title, uri, date, context }) =>
`
${context}
`,
- empty: ({ query }) => `${searchConfig.noResultsFound}: "${Util.HTMLEscape(query)}"
`,
+ empty: ({ query }) => `${searchConfig.noResultsFound}: "${HTMLEscape(query)}"
`,
footer: ({ }) => {
let searchType, icon, href;
switch (searchConfig.type) {
@@ -376,7 +390,7 @@ class FixIt {
document.getElementById('mask')?.click();
window.location.assign(suggestion.uri);
});
- if (isMobile) {
+ if (_isMobile) {
this._searchMobile = autosearch;
} else {
this._searchDesktop = autosearch;
@@ -394,7 +408,7 @@ class FixIt {
}
initDetails(target = document) {
- Util.forEach(target.querySelectorAll('.details:not(.disabled)'), ($details) => {
+ forEach(target.querySelectorAll('.details:not(.disabled)'), ($details) => {
const $summary = $details.querySelector('.details-summary');
$summary.addEventListener('click', () => {
$details.classList.toggle('open');
@@ -436,11 +450,11 @@ class FixIt {
const iswWrap = codeBlock.classList.contains('line-wrapping');
const highlightLines = codeBlock.querySelectorAll('.hl');
iswWrap && codeBlock.classList.toggle('line-wrapping');
- Util.forEach(highlightLines, $hl => $hl.classList.toggle('hl'));
- Util.copyText(codePreEl.innerText.trim()).then(() => {
- Util.animateCSS(codePreEl, 'animate__flash');
+ forEach(highlightLines, $hl => $hl.classList.toggle('hl'));
+ copyText(codePreEl.innerText.trim()).then(() => {
+ animateCSS(codePreEl, 'animate__flash');
iswWrap && codeBlock.classList.toggle('line-wrapping');
- Util.forEach(highlightLines, $hl => $hl.classList.toggle('hl'));
+ forEach(highlightLines, $hl => $hl.classList.toggle('hl'));
const copiedText = copyBtn.dataset.copiedText;
const originalTitle = copyBtn.dataset.ctOriginalTitle;
copyBtn.toggleAttribute('data-copied', true);
@@ -551,7 +565,7 @@ class FixIt {
*/
initCodeWrapper() {
const $codeBlocks = document.querySelectorAll('.code-block.highlight:not([data-init])');
- Util.forEach($codeBlocks, ($codeBlock) => {
+ forEach($codeBlocks, ($codeBlock) => {
const $preElements = $codeBlock.querySelectorAll('pre.chroma');
if (!$preElements.length) return;
const $codePreEl = $preElements[$preElements.length - 1];
@@ -591,7 +605,7 @@ class FixIt {
$codePreEl.setAttribute('contenteditable', false);
$codePreEl.blur();
} else {
- Util.forEach($codeBlock.querySelectorAll('.hl'), ($hl) => {
+ forEach($codeBlock.querySelectorAll('.hl'), ($hl) => {
$hl.classList.remove('hl');
});
$codeBlock.classList.add('is-expanded');
@@ -611,7 +625,7 @@ class FixIt {
const $codeBlocks = document.querySelectorAll('.code-block[group]:not([data-tab-init])');
const processed = new Set();
- Util.forEach($codeBlocks, ($block) => {
+ forEach($codeBlocks, ($block) => {
if (processed.has($block)) return;
const groupName = $block.getAttribute('group');
@@ -693,7 +707,7 @@ class FixIt {
.tab-item[title="${$tab.dataset.tabTitle.toLowerCase()}"]:not(.active),
.tab-item[title="${$tab.dataset.tabTitle.toUpperCase()}"]:not(.active)`
);
- Util.forEach(activeItems, t => t.click());
+ forEach(activeItems, t => t.click());
}
// 3. switch content
@@ -741,15 +755,15 @@ class FixIt {
* init diagram copy button
*/
initDiagramCopyBtn() {
- const stagingDOM = Util.getStagingDOM()
- Util.forEach(document.querySelectorAll('.diagram-copy-btn'), ($btn) => {
+ const stagingDOM = getStagingDOM()
+ forEach(document.querySelectorAll('.diagram-copy-btn'), ($btn) => {
$btn.addEventListener('click', () => {
stagingDOM.stage($btn.parentElement.querySelector('template').content.cloneNode(true))
let code = stagingDOM.contentAsText();
try {
code = JSON.stringify(JSON.parse(code), null, 2);
} catch { }
- Util.copyText(code).then(() => {
+ copyText(code).then(() => {
$btn.toggleAttribute('data-copied', true);
setTimeout(() => {
$btn.toggleAttribute('data-copied', false);
@@ -773,10 +787,10 @@ class FixIt {
const $tocLiElements = $tocContainer.getElementsByTagName('li');
// Remove all active classes
- Util.forEach($tocLinkElements, ($tocLink) => {
+ forEach($tocLinkElements, ($tocLink) => {
$tocLink.classList.remove('active');
});
- Util.forEach($tocLiElements, ($tocLi) => {
+ forEach($tocLiElements, ($tocLi) => {
$tocLi.classList.remove('has-active');
});
@@ -815,10 +829,10 @@ class FixIt {
// TOC Drawer Button Visibility
const openButton = document.querySelector("#toc-drawer-button");
if (openButton) {
- openButton.classList.toggle('d-none', !Util.isTocStatic());
+ openButton.classList.toggle('d-none', !isTocStatic());
}
// TOC Static and TOC Dialog
- if (Util.isTocStatic()) {
+ if (isTocStatic()) {
const $tocContentStatic = document.getElementById('toc-content-static');
if ($tocCore.parentElement !== $tocContentStatic) {
$tocCore.parentElement.removeChild($tocCore);
@@ -845,7 +859,7 @@ class FixIt {
}
const $toc = document.getElementById('toc-auto');
$toc.style.visibility = 'visible';
- Util.animateCSS($toc, ['animate__fadeIn', 'animate__faster'], true);
+ animateCSS($toc, ['animate__fadeIn', 'animate__faster'], true);
const $postMeta = document.querySelector('.post-meta');
$toc.style.marginTop = `${$postMeta.offsetTop + $postMeta.clientHeight}px`;
@@ -874,7 +888,7 @@ class FixIt {
} else {
$tocContentAuto.classList.remove('animate__fadeIn');
}
- Util.animateCSS($tocContentAuto, animation, true, () => {
+ animateCSS($tocContentAuto, animation, true, () => {
$tocContentAuto.classList.contains('animate__fadeOut') && $tocContentAuto.classList.add('d-none');
});
$toc.classList.toggle('toc-hidden');
@@ -909,7 +923,7 @@ class FixIt {
$tocCore = $newTocCore;
}
// remove APlayer click event listener of the heading mark
- Util.forEach(document.querySelectorAll('.heading-mark'), ($headingMark) => {
+ forEach(document.querySelectorAll('.heading-mark'), ($headingMark) => {
const $newHeadingMark = $headingMark.cloneNode(true);
$headingMark.parentElement.replaceChild($newHeadingMark, $headingMark);
});
@@ -926,8 +940,8 @@ class FixIt {
this._echartsArr[i].dispose();
}
this._echartsArr = [];
- const stagingDOM = Util.getStagingDOM()
- Util.forEach(document.getElementsByClassName('echarts'), ($echarts) => {
+ const stagingDOM = getStagingDOM()
+ forEach(document.getElementsByClassName('echarts'), ($echarts) => {
const $dataEl = $echarts.nextElementSibling;
if ($dataEl.tagName !== 'TEMPLATE') return;
const chart = echarts.init($echarts, this.isDark ? 'dark' : 'light', { renderer: 'svg' });
@@ -959,7 +973,7 @@ class FixIt {
* @returns {Object|Promise} ECharts option or Promise
*/
const _getOption = new Function('fixit', 'chart',
- Util.isObjectLiteral(jsCodes) ? `return ${jsCodes}` : jsCodes
+ isObjectLiteral(jsCodes) ? `return ${jsCodes}` : jsCodes
);
if ($dataEl.dataset.async === 'true') {
return Promise.resolve(_getOption(this, chart)).then(option => {
@@ -993,7 +1007,7 @@ class FixIt {
mapboxgl.setRTLTextPlugin(this.config.mapbox.RTLTextPlugin);
this._mapboxArr = this._mapboxArr || [];
}
- Util.forEach(document.querySelectorAll('.mapbox:empty'), ($mapbox) => {
+ forEach(document.querySelectorAll('.mapbox:empty'), ($mapbox) => {
const { lng, lat, zoom, lightStyle, darkStyle, marked, markers, navigation, geolocate, scale, fullscreen } = JSON.parse($mapbox.dataset.options);
const mapbox = new mapboxgl.Map({
container: $mapbox,
@@ -1042,7 +1056,7 @@ class FixIt {
this._mapboxArr.push(mapbox);
});
this._mapboxOnSwitchTheme = this._mapboxOnSwitchTheme || (() => {
- Util.forEach(this._mapboxArr, (mapbox) => {
+ forEach(this._mapboxArr, (mapbox) => {
const $mapbox = mapbox.getContainer();
const { lightStyle, darkStyle } = JSON.parse($mapbox.dataset.options);
mapbox.setStyle(this.isDark ? darkStyle : lightStyle);
@@ -1069,7 +1083,7 @@ class FixIt {
acc[group].push(ele);
return acc;
}, {});
- const stagingDOM = Util.getStagingDOM()
+ const stagingDOM = getStagingDOM()
Object.values(groupMap).forEach((group) => {
const typeone = (i) => {
@@ -1143,7 +1157,7 @@ class FixIt {
$viewCommentsBtn.classList.remove('d-none');
// view comments button click event
$viewCommentsBtn.addEventListener('click', () => {
- Util.scrollIntoView('#comments');
+ scrollIntoView('#comments');
}, false);
}
this.config.comment.expired && document.querySelector('#comments').remove();
@@ -1260,7 +1274,7 @@ class FixIt {
let now = new Date();
let run = new Date(this.config.siteTime);
let $runTimes = document.querySelector('.run-times');
- if (!Util.isValidDate(run) || !$runTimes) {
+ if (!isValidDate(run) || !$runTimes) {
clearInterval(this.siteTime);
$runTimes && $runTimes.parentNode.remove();
return;
@@ -1338,7 +1352,7 @@ class FixIt {
initJsonViewer() {
if (!window.JsonViewerElement) return;
this._jsonViewerOnSwitchTheme = this._jsonViewerOnSwitchTheme || (() => {
- Util.forEach(document.getElementsByTagName('json-viewer'), ($el) => {
+ forEach(document.getElementsByTagName('json-viewer'), ($el) => {
$el.setAttribute('theme', this.isDark ? 'dark' : 'light');
});
});
@@ -1434,7 +1448,7 @@ class FixIt {
_toggleEncryptedClass(container, show) {
const fromClass = show ? 'encrypted-hidden' : 'decrypted-shown';
const toClass = show ? 'decrypted-shown' : 'encrypted-hidden';
- Util.forEach(container.querySelectorAll(`.${fromClass}`), ($element) => {
+ forEach(container.querySelectorAll(`.${fromClass}`), ($element) => {
$element.classList.replace(fromClass, toClass);
});
}
@@ -1459,7 +1473,7 @@ class FixIt {
initAutoMark() {
if (!this.config.autoBookmark) return;
window.addEventListener('beforeunload', () => {
- window.sessionStorage?.setItem(`fixit-bookmark/#${location.pathname}`, Util.getScrollTop());
+ window.sessionStorage?.setItem(`fixit-bookmark/#${location.pathname}`, getScrollTop());
});
const scrollTop = Number(window.sessionStorage?.getItem(`fixit-bookmark/#${location.pathname}`));
// If the page opens with a specific hash, just jump out
@@ -1475,15 +1489,15 @@ class FixIt {
const $rewards = document.querySelectorAll('.post-reward [data-mode="fixed"]');
if (!$rewards.length) return;
// `fixed` mode only supports desktop
- if (Util.isMobile()) {
- Util.forEach($rewards, ($reward) => {
+ if (isMobile()) {
+ forEach($rewards, ($reward) => {
$reward.removeAttribute('data-mode');
});
return;
}
// Close post reward images exclude special id
const _closeRewardExclude = (id) => {
- Util.forEach($rewards, ($reward) => {
+ forEach($rewards, ($reward) => {
const $rewardInput = $reward.parentElement.querySelector('.reward-input');
if ($rewardInput.id !== id) {
$rewardInput.checked = false;
@@ -1491,7 +1505,7 @@ class FixIt {
});
};
// Add additional click event to reward buttons
- Util.forEach($rewards, ($reward) => {
+ forEach($rewards, ($reward) => {
$reward.previousElementSibling.addEventListener('click', function () {
_closeRewardExclude(this.getAttribute('for'));
}, false)
@@ -1524,7 +1538,7 @@ class FixIt {
$headers.push(document.getElementById('header-mobile'));
}
$backToTop?.addEventListener('click', () => {
- Util.scrollIntoView('body');
+ scrollIntoView('body');
});
window.addEventListener('scroll', (event) => {
if (this.disableScrollEvent) {
@@ -1532,17 +1546,17 @@ class FixIt {
return;
}
const $mask = document.getElementById('mask');
- this.newScrollTop = Util.getScrollTop();
+ this.newScrollTop = getScrollTop();
const scroll = this.newScrollTop - this.oldScrollTop;
// header animation
- Util.forEach($headers, ($header) => {
+ forEach($headers, ($header) => {
if (scroll > ACCURACY) {
$header.classList.remove('animate__fadeInDown');
- Util.animateCSS($header, ['animate__fadeOutUp'], true);
+ animateCSS($header, ['animate__fadeOutUp'], true);
$mask.click();
} else if (scroll < -ACCURACY) {
$header.classList.remove('animate__fadeOutUp');
- Util.animateCSS($header, ['animate__fadeInDown'], true);
+ animateCSS($header, ['animate__fadeInDown'], true);
$mask.click();
}
});
@@ -1555,10 +1569,10 @@ class FixIt {
if ($backToTop) {
if (scrollPercent > 1) {
$backToTop.classList.remove('d-none', 'animate__fadeOut');
- Util.animateCSS($backToTop, ['animate__fadeIn'], true);
+ animateCSS($backToTop, ['animate__fadeIn'], true);
} else {
$backToTop.classList.remove('animate__fadeIn');
- Util.animateCSS($backToTop, ['animate__fadeOut'], true, () => {
+ animateCSS($backToTop, ['animate__fadeOut'], true, () => {
$backToTop.classList.contains('animate__fadeOut') && $backToTop.classList.add('d-none');
});
}
@@ -1578,7 +1592,7 @@ class FixIt {
}
onResize() {
- let resizeBefore = Util.isMobile();
+ let resizeBefore = isMobile();
window.addEventListener('resize', () => {
if (!this._resizeTimeout) {
this._resizeTimeout = window.setTimeout(() => {
@@ -1589,10 +1603,10 @@ class FixIt {
this.initToc();
this.initSearch();
- const isMobile = Util.isMobile()
- if (isMobile !== resizeBefore) {
+ const _isMobile = isMobile();
+ if (_isMobile !== resizeBefore) {
document.getElementById('mask').click();
- resizeBefore = isMobile;
+ resizeBefore = _isMobile;
}
}, 100);
}
@@ -1616,11 +1630,11 @@ class FixIt {
const printConfig = this.config.print || {};
if (printConfig.expandAdmonition) {
- Util.forEach($content.querySelectorAll('.admonition'), ($el) => $el.classList.add('open'));
+ forEach($content.querySelectorAll('.admonition'), ($el) => $el.classList.add('open'));
}
if (printConfig.expandCode) {
// revert code tabs to code blocks for better printing support
- Util.forEach($content.querySelectorAll('.code-tabs'), ($codeTabs) => {
+ forEach($content.querySelectorAll('.code-tabs'), ($codeTabs) => {
// restore action buttons to the active tab's code-header before reverting
const $actions = $codeTabs.querySelector('.tabs-actions');
const $activeBlock = $codeTabs.querySelector('.code-block.active');
@@ -1637,7 +1651,7 @@ class FixIt {
});
$codeTabs.parentElement.removeChild($codeTabs);
});
- Util.forEach($content.querySelectorAll('.code-block'), ($el) => {
+ forEach($content.querySelectorAll('.code-block'), ($el) => {
// line wrapping
$el.classList.add('line-wrapping');
// expand all code blocks
@@ -1649,7 +1663,7 @@ class FixIt {
});
}
if (printConfig.expandDetails) {
- Util.forEach($content.querySelectorAll('details'), ($el) => $el.setAttribute('open', ''));
+ forEach($content.querySelectorAll('details'), ($el) => $el.setAttribute('open', ''));
}
for (let event of this.beforeprintEventSet) {
event();
diff --git a/assets/js/util.js b/assets/js/util.js
deleted file mode 100644
index b0049e71..00000000
--- a/assets/js/util.js
+++ /dev/null
@@ -1,146 +0,0 @@
-export default class Util {
- static forEach(elements, handler) {
- elements = elements || [];
- const promises = [];
- for (let i = 0; i < elements.length; i++) {
- const result = handler(elements[i], i);
- if (result instanceof Promise) {
- promises.push(result);
- }
- }
- return Promise.all(promises);
- }
-
- static getScrollTop() {
- return (document.documentElement ?? document.body).scrollTop;
- }
-
- static isMobile() {
- return window.matchMedia('only screen and (max-width: 680px)').matches;
- }
-
- static isTocStatic() {
- return document.getElementById('toc-static').dataset.kept === 'true' || window.matchMedia('only screen and (max-width: 960px)').matches;
- }
-
- /**
- * add animate to element
- * @param {Element} element animate element
- * @param {String|Array} animation animation name
- * @param {Boolean} reserved reserved animation
- * @param {Function} callback remove callback
- */
- static animateCSS(element, animation, reserved, callback) {
- !Array.isArray(animation) && (animation = [animation]);
- element.classList.add('animate__animated', ...animation);
- element.addEventListener('animationend', () => {
- !reserved && element.classList.remove('animate__animated', ...animation);
- typeof callback === 'function' && callback();
- }, { once: true });
- }
-
- /**
- * date validator
- * @param {*} date may be date or not
- * @returns {Boolean}
- */
- static isValidDate(date) {
- return date instanceof Date && !isNaN(date.getTime());
- }
-
- /**
- * scroll some element into view
- * @param {String} selector element to scroll
- */
- static scrollIntoView(selector) {
- const element = selector.startsWith('#')
- ? document.getElementById(selector.slice(1))
- : document.querySelector(selector);
- element?.scrollIntoView({
- behavior: 'smooth'
- });
- }
-
- /**
- * get a hidden element for temporary use
- * @returns {Object} { $el: Element, destroy: Function }
- */
- static getStagingDOM() {
- const stagingElement = document.createElement('div')
- stagingElement.style.display = 'none';
- stagingElement.dataset.stagingId = Math.random().toString(36).slice(2);
- document.body.appendChild(stagingElement);
-
- return {
- $el: stagingElement,
- stage(dom) {
- stagingElement.innerHTML = '';
- stagingElement.appendChild(dom);
- },
- contentAsHtml() {
- return stagingElement.innerHTML;
- },
- contentAsText() {
- return stagingElement.innerText;
- },
- contentAsJson() {
- return JSON.parse(stagingElement.innerHTML);
- },
- destroy() {
- document.body.removeChild(stagingElement);
- }
- }
- }
-
- /**
- * copy text to clipboard
- * @param {String} text text to copy
- * @returns {Promise} promise
- */
- static copyText(text) {
- if (navigator.clipboard) {
- this.copyText = (text) => navigator.clipboard.writeText(text);
- return this.copyText(text);
- }
- this.copyText = (text) => new Promise((resolve, reject) => {
- const input = document.createElement('input');
- input.value = text;
- document.body.appendChild(input);
- input.select();
- if (document.execCommand('copy')) {
- document.body.removeChild(input);
- resolve();
- } else {
- reject();
- }
- });
- return this.copyText(text);
- }
-
- /**
- * check if a string is a JS object string
- * @example isObjectLiteral("{a:1,b:2}") // true
- * @param {String} str string to check
- * @returns {Boolean} whether the string is a JS object string
- */
- static isObjectLiteral(str) {
- if (typeof str !== 'string') {
- return false;
- }
- str = str.replace(/\s+/g, ' ').trim().replace(/;$/, '')
- if (str.startsWith('{') && str.endsWith('}')) {
- return true;
- }
- return false;
- }
-
- static HTMLEscape(str) {
- return str.replace(/[&<>"']/g, char => ({
- '&': '&',
- '<': '<',
- '>': '>',
- '"': '"',
- "'": '''
- })[char]);
- }
-}
diff --git a/assets/js/utils/common.js b/assets/js/utils/common.js
new file mode 100644
index 00000000..8b6310e7
--- /dev/null
+++ b/assets/js/utils/common.js
@@ -0,0 +1,141 @@
+export function forEach(elements, handler) {
+ elements = elements || [];
+ const promises = [];
+ for (let i = 0; i < elements.length; i++) {
+ const result = handler(elements[i], i);
+ if (result instanceof Promise) {
+ promises.push(result);
+ }
+ }
+ return Promise.all(promises);
+}
+
+export function getScrollTop() {
+ return (document.documentElement ?? document.body).scrollTop;
+}
+
+export function isMobile() {
+ return window.matchMedia('only screen and (max-width: 680px)').matches;
+}
+
+export function isTocStatic() {
+ return document.getElementById('toc-static').dataset.kept === 'true' || window.matchMedia('only screen and (max-width: 960px)').matches;
+}
+
+/**
+ * add animate to element
+ * @param {Element} element animate element
+ * @param {String|Array} animation animation name
+ * @param {Boolean} reserved reserved animation
+ * @param {Function} callback remove callback
+ */
+export function animateCSS(element, animation, reserved, callback) {
+ !Array.isArray(animation) && (animation = [animation]);
+ element.classList.add('animate__animated', ...animation);
+ element.addEventListener('animationend', () => {
+ !reserved && element.classList.remove('animate__animated', ...animation);
+ typeof callback === 'function' && callback();
+ }, { once: true });
+}
+
+/**
+ * date validator
+ * @param {*} date may be date or not
+ * @returns {Boolean}
+ */
+export function isValidDate(date) {
+ return date instanceof Date && !isNaN(date.getTime());
+}
+
+/**
+ * scroll some element into view
+ * @param {String} selector element to scroll
+ */
+export function scrollIntoView(selector) {
+ const element = selector.startsWith('#')
+ ? document.getElementById(selector.slice(1))
+ : document.querySelector(selector);
+ element?.scrollIntoView({
+ behavior: 'smooth'
+ });
+}
+
+/**
+ * get a hidden element for temporary use
+ * @returns {Object} { $el: Element, destroy: Function }
+ */
+export function getStagingDOM() {
+ const stagingElement = document.createElement('div')
+ stagingElement.style.display = 'none';
+ stagingElement.dataset.stagingId = Math.random().toString(36).slice(2);
+ document.body.appendChild(stagingElement);
+
+ return {
+ $el: stagingElement,
+ stage(dom) {
+ stagingElement.innerHTML = '';
+ stagingElement.appendChild(dom);
+ },
+ contentAsHtml() {
+ return stagingElement.innerHTML;
+ },
+ contentAsText() {
+ return stagingElement.innerText;
+ },
+ contentAsJson() {
+ return JSON.parse(stagingElement.innerHTML);
+ },
+ destroy() {
+ document.body.removeChild(stagingElement);
+ }
+ }
+}
+
+/**
+ * create a copy text function with fallback
+ * @returns {Function} copy text function
+ */
+export function createCopyText() {
+ if (navigator.clipboard) {
+ return (text) => navigator.clipboard.writeText(text);
+ }
+ return (text) => new Promise((resolve, reject) => {
+ const input = document.createElement('input');
+ input.value = text;
+ document.body.appendChild(input);
+ input.select();
+ if (document.execCommand('copy')) {
+ document.body.removeChild(input);
+ resolve();
+ } else {
+ reject();
+ }
+ });
+}
+
+/**
+ * check if a string is a JS object string
+ * @example isObjectLiteral("{a:1,b:2}") // true
+ * @param {String} str string to check
+ * @returns {Boolean} whether the string is a JS object string
+ */
+export function isObjectLiteral(str) {
+ if (typeof str !== 'string') {
+ return false;
+ }
+ str = str.replace(/\s+/g, ' ').trim().replace(/;$/, '')
+ if (str.startsWith('{') && str.endsWith('}')) {
+ return true;
+ }
+ return false;
+}
+
+export function HTMLEscape(str) {
+ return str.replace(/[&<>"']/g, char => ({
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": '''
+ })[char]);
+}
diff --git a/layouts/_partials/init/index.html b/layouts/_partials/init/index.html
index 828ee1be..1301345e 100644
--- a/layouts/_partials/init/index.html
+++ b/layouts/_partials/init/index.html
@@ -1,4 +1,4 @@
-{{- hugo.Store.Set "version" "v0.4.5-20260320183805-d5aa0131" -}}
+{{- hugo.Store.Set "version" "v0.4.5-20260324033631-a53ef800" -}}
{{- .Store.Set "this" dict -}}
{{- partial "init/detection-env.html" . -}}