wip(js): add head.html and assets/js
This commit is contained in:
		
							
								
								
									
										10
									
								
								assets/js/modules/components.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								assets/js/modules/components.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| export { categoryCollapse } from './components/category-collapse'; | ||||
| export { initClipboard } from './components/clipboard'; | ||||
| export { loadImg } from './components/img-loading'; | ||||
| export { imgPopup } from './components/img-popup'; | ||||
| export { initLocaleDatetime } from './components/locale-datetime'; | ||||
| export { initToc } from './components/toc'; | ||||
| export { loadMermaid } from './components/mermaid'; | ||||
| export { modeWatcher } from './components/mode-toggle'; | ||||
| export { back2top } from './components/back-to-top'; | ||||
| export { loadTooptip } from './components/tooltip-loader'; | ||||
							
								
								
									
										19
									
								
								assets/js/modules/components/back-to-top.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								assets/js/modules/components/back-to-top.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| /** | ||||
|  * Reference: https://bootsnipp.com/snippets/featured/link-to-top-page | ||||
|  */ | ||||
|  | ||||
| export function back2top() { | ||||
|   const btn = document.getElementById('back-to-top'); | ||||
|  | ||||
|   window.addEventListener('scroll', () => { | ||||
|     if (window.scrollY > 50) { | ||||
|       btn.classList.add('show'); | ||||
|     } else { | ||||
|       btn.classList.remove('show'); | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   btn.addEventListener('click', () => { | ||||
|     window.scrollTo({ top: 0 }); | ||||
|   }); | ||||
| } | ||||
							
								
								
									
										36
									
								
								assets/js/modules/components/category-collapse.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								assets/js/modules/components/category-collapse.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | ||||
| /** | ||||
|  * Tab 'Categories' expand/close effect. | ||||
|  */ | ||||
|  | ||||
| import 'bootstrap/js/src/collapse.js'; | ||||
|  | ||||
| const childPrefix = 'l_'; | ||||
| const parentPrefix = 'h_'; | ||||
| const children = document.getElementsByClassName('collapse'); | ||||
|  | ||||
| export function categoryCollapse() { | ||||
|   [...children].forEach((elem) => { | ||||
|     const id = parentPrefix + elem.id.substring(childPrefix.length); | ||||
|     const parent = document.getElementById(id); | ||||
|  | ||||
|     // collapse sub-categories | ||||
|     elem.addEventListener('hide.bs.collapse', () => { | ||||
|       if (parent) { | ||||
|         parent.querySelector('.far.fa-folder-open').className = | ||||
|           'far fa-folder fa-fw'; | ||||
|         parent.querySelector('.fas.fa-angle-down').classList.add('rotate'); | ||||
|         parent.classList.remove('hide-border-bottom'); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     // expand sub-categories | ||||
|     elem.addEventListener('show.bs.collapse', () => { | ||||
|       if (parent) { | ||||
|         parent.querySelector('.far.fa-folder').className = | ||||
|           'far fa-folder-open fa-fw'; | ||||
|         parent.querySelector('.fas.fa-angle-down').classList.remove('rotate'); | ||||
|         parent.classList.add('hide-border-bottom'); | ||||
|       } | ||||
|     }); | ||||
|   }); | ||||
| } | ||||
							
								
								
									
										143
									
								
								assets/js/modules/components/clipboard.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										143
									
								
								assets/js/modules/components/clipboard.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,143 @@ | ||||
| /** | ||||
|  * Clipboard functions | ||||
|  * | ||||
|  * Dependencies: | ||||
|  *    clipboard.js (https://github.com/zenorocha/clipboard.js) | ||||
|  */ | ||||
|  | ||||
| import Tooltip from 'bootstrap/js/src/tooltip'; | ||||
|  | ||||
| const clipboardSelector = '.code-header>button'; | ||||
|  | ||||
| const ICON_DEFAULT = 'far fa-clipboard'; | ||||
| const ICON_SUCCESS = 'fas fa-check'; | ||||
|  | ||||
| const ATTR_TIMEOUT = 'timeout'; | ||||
| const ATTR_TITLE_SUCCEED = 'data-title-succeed'; | ||||
| const ATTR_TITLE_ORIGIN = 'data-bs-original-title'; | ||||
| const TIMEOUT = 2000; // in milliseconds | ||||
|  | ||||
| function isLocked(node) { | ||||
|   if (node.hasAttribute(ATTR_TIMEOUT)) { | ||||
|     let timeout = node.getAttribute(ATTR_TIMEOUT); | ||||
|     if (Number(timeout) > Date.now()) { | ||||
|       return true; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return false; | ||||
| } | ||||
|  | ||||
| function lock(node) { | ||||
|   node.setAttribute(ATTR_TIMEOUT, Date.now() + TIMEOUT); | ||||
| } | ||||
|  | ||||
| function unlock(node) { | ||||
|   node.removeAttribute(ATTR_TIMEOUT); | ||||
| } | ||||
|  | ||||
| function showTooltip(btn) { | ||||
|   const succeedTitle = btn.getAttribute(ATTR_TITLE_SUCCEED); | ||||
|   btn.setAttribute(ATTR_TITLE_ORIGIN, succeedTitle); | ||||
|   Tooltip.getInstance(btn).show(); | ||||
| } | ||||
|  | ||||
| function hideTooltip(btn) { | ||||
|   Tooltip.getInstance(btn).hide(); | ||||
|   btn.removeAttribute(ATTR_TITLE_ORIGIN); | ||||
| } | ||||
|  | ||||
| function setSuccessIcon(btn) { | ||||
|   const icon = btn.children[0]; | ||||
|   icon.setAttribute('class', ICON_SUCCESS); | ||||
| } | ||||
|  | ||||
| function resumeIcon(btn) { | ||||
|   const icon = btn.children[0]; | ||||
|   icon.setAttribute('class', ICON_DEFAULT); | ||||
| } | ||||
|  | ||||
| function setCodeClipboard() { | ||||
|   const clipboardList = document.querySelectorAll(clipboardSelector); | ||||
|  | ||||
|   if (clipboardList.length === 0) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   // Initial the clipboard.js object | ||||
|   const clipboard = new ClipboardJS(clipboardSelector, { | ||||
|     target: (trigger) => { | ||||
|       const codeBlock = trigger.parentNode.nextElementSibling; | ||||
|       return codeBlock.querySelector('code .rouge-code'); | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   [...clipboardList].map( | ||||
|     (elem) => | ||||
|       new Tooltip(elem, { | ||||
|         placement: 'left' | ||||
|       }) | ||||
|   ); | ||||
|  | ||||
|   clipboard.on('success', (e) => { | ||||
|     const trigger = e.trigger; | ||||
|  | ||||
|     e.clearSelection(); | ||||
|  | ||||
|     if (isLocked(trigger)) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     setSuccessIcon(trigger); | ||||
|     showTooltip(trigger); | ||||
|     lock(trigger); | ||||
|  | ||||
|     setTimeout(() => { | ||||
|       hideTooltip(trigger); | ||||
|       resumeIcon(trigger); | ||||
|       unlock(trigger); | ||||
|     }, TIMEOUT); | ||||
|   }); | ||||
| } | ||||
|  | ||||
| function setLinkClipboard() { | ||||
|   const btnCopyLink = document.getElementById('copy-link'); | ||||
|  | ||||
|   if (btnCopyLink === null) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   btnCopyLink.addEventListener('click', (e) => { | ||||
|     const target = e.target; | ||||
|  | ||||
|     if (isLocked(target)) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Copy URL to clipboard | ||||
|     navigator.clipboard.writeText(window.location.href).then(() => { | ||||
|       const defaultTitle = target.getAttribute(ATTR_TITLE_ORIGIN); | ||||
|       const succeedTitle = target.getAttribute(ATTR_TITLE_SUCCEED); | ||||
|  | ||||
|       // Switch tooltip title | ||||
|       target.setAttribute(ATTR_TITLE_ORIGIN, succeedTitle); | ||||
|       Tooltip.getInstance(target).show(); | ||||
|  | ||||
|       lock(target); | ||||
|  | ||||
|       setTimeout(() => { | ||||
|         target.setAttribute(ATTR_TITLE_ORIGIN, defaultTitle); | ||||
|         unlock(target); | ||||
|       }, TIMEOUT); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   btnCopyLink.addEventListener('mouseleave', (e) => { | ||||
|     Tooltip.getInstance(e.target).hide(); | ||||
|   }); | ||||
| } | ||||
|  | ||||
| export function initClipboard() { | ||||
|   setCodeClipboard(); | ||||
|   setLinkClipboard(); | ||||
| } | ||||
							
								
								
									
										67
									
								
								assets/js/modules/components/img-loading.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								assets/js/modules/components/img-loading.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,67 @@ | ||||
| /** | ||||
|  * Setting up image lazy loading and LQIP switching | ||||
|  */ | ||||
|  | ||||
| const ATTR_DATA_SRC = 'data-src'; | ||||
| const ATTR_DATA_LQIP = 'data-lqip'; | ||||
|  | ||||
| const cover = { | ||||
|   SHIMMER: 'shimmer', | ||||
|   BLUR: 'blur' | ||||
| }; | ||||
|  | ||||
| function removeCover(clzss) { | ||||
|   this.parentElement.classList.remove(clzss); | ||||
| } | ||||
|  | ||||
| function handleImage() { | ||||
|   if (!this.complete) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   if (this.hasAttribute(ATTR_DATA_LQIP)) { | ||||
|     removeCover.call(this, cover.BLUR); | ||||
|   } else { | ||||
|     removeCover.call(this, cover.SHIMMER); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Switches the LQIP with the real image URL. | ||||
|  */ | ||||
| function switchLQIP() { | ||||
|   const src = this.getAttribute(ATTR_DATA_SRC); | ||||
|   this.setAttribute('src', encodeURI(src)); | ||||
|   this.removeAttribute(ATTR_DATA_SRC); | ||||
| } | ||||
|  | ||||
| export function loadImg() { | ||||
|   const images = document.querySelectorAll('article img'); | ||||
|  | ||||
|   if (images.length === 0) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   images.forEach((img) => { | ||||
|     img.addEventListener('load', handleImage); | ||||
|   }); | ||||
|  | ||||
|   // Images loaded from the browser cache do not trigger the 'load' event | ||||
|   document.querySelectorAll('article img[loading="lazy"]').forEach((img) => { | ||||
|     if (img.complete) { | ||||
|       removeCover.call(img, cover.SHIMMER); | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   // LQIPs set by the data URI or WebP will not trigger the 'load' event, | ||||
|   // so manually convert the URI to the URL of a high-resolution image. | ||||
|   const lqips = document.querySelectorAll( | ||||
|     `article img[${ATTR_DATA_LQIP}="true"]` | ||||
|   ); | ||||
|  | ||||
|   if (lqips.length) { | ||||
|     lqips.forEach((lqip) => { | ||||
|       switchLQIP.call(lqip); | ||||
|     }); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										50
									
								
								assets/js/modules/components/img-popup.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								assets/js/modules/components/img-popup.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,50 @@ | ||||
| /** | ||||
|  * Set up image popup | ||||
|  * | ||||
|  * Dependencies: https://github.com/biati-digital/glightbox | ||||
|  */ | ||||
|  | ||||
| const lightImages = '.popup:not(.dark)'; | ||||
| const darkImages = '.popup:not(.light)'; | ||||
| let selector = lightImages; | ||||
|  | ||||
| function updateImages(current, reverse) { | ||||
|   if (selector === lightImages) { | ||||
|     selector = darkImages; | ||||
|   } else { | ||||
|     selector = lightImages; | ||||
|   } | ||||
|  | ||||
|   if (reverse === null) { | ||||
|     reverse = GLightbox({ selector: `${selector}` }); | ||||
|   } | ||||
|  | ||||
|   [current, reverse] = [reverse, current]; | ||||
| } | ||||
|  | ||||
| export function imgPopup() { | ||||
|   if (document.querySelector('.popup') === null) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   const hasDualImages = !( | ||||
|     document.querySelector('.popup.light') === null && | ||||
|     document.querySelector('.popup.dark') === null | ||||
|   ); | ||||
|  | ||||
|   if (Theme.visualState === Theme.DARK) { | ||||
|     selector = darkImages; | ||||
|   } | ||||
|  | ||||
|   let current = GLightbox({ selector: `${selector}` }); | ||||
|  | ||||
|   if (hasDualImages && Theme.switchable) { | ||||
|     let reverse = null; | ||||
|  | ||||
|     window.addEventListener('message', (event) => { | ||||
|       if (event.source === window && event.data && event.data.id === Theme.ID) { | ||||
|         updateImages(current, reverse); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										53
									
								
								assets/js/modules/components/locale-datetime.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								assets/js/modules/components/locale-datetime.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | ||||
| /** | ||||
|  * Update month/day to locale datetime | ||||
|  * | ||||
|  * Requirement: <https://github.com/iamkun/dayjs> | ||||
|  */ | ||||
|  | ||||
| /* A tool for locale datetime */ | ||||
| class LocaleHelper { | ||||
|   static get attrTimestamp() { | ||||
|     return 'data-ts'; | ||||
|   } | ||||
|  | ||||
|   static get attrDateFormat() { | ||||
|     return 'data-df'; | ||||
|   } | ||||
|  | ||||
|   static get locale() { | ||||
|     return document.documentElement.getAttribute('lang').substring(0, 2); | ||||
|   } | ||||
|  | ||||
|   static getTimestamp(elem) { | ||||
|     return Number(elem.getAttribute(this.attrTimestamp)); // unix timestamp | ||||
|   } | ||||
|  | ||||
|   static getDateFormat(elem) { | ||||
|     return elem.getAttribute(this.attrDateFormat); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function initLocaleDatetime() { | ||||
|   dayjs.locale(LocaleHelper.locale); | ||||
|   dayjs.extend(window.dayjs_plugin_localizedFormat); | ||||
|  | ||||
|   document | ||||
|     .querySelectorAll(`[${LocaleHelper.attrTimestamp}]`) | ||||
|     .forEach((elem) => { | ||||
|       const date = dayjs.unix(LocaleHelper.getTimestamp(elem)); | ||||
|       const text = date.format(LocaleHelper.getDateFormat(elem)); | ||||
|       elem.textContent = text; | ||||
|       elem.removeAttribute(LocaleHelper.attrTimestamp); | ||||
|       elem.removeAttribute(LocaleHelper.attrDateFormat); | ||||
|  | ||||
|       // setup tooltips | ||||
|       if ( | ||||
|         elem.hasAttribute('data-bs-toggle') && | ||||
|         elem.getAttribute('data-bs-toggle') === 'tooltip' | ||||
|       ) { | ||||
|         // see: https://day.js.org/docs/en/display/format#list-of-localized-formats | ||||
|         const tooltipText = date.format('llll'); | ||||
|         elem.setAttribute('data-bs-title', tooltipText); | ||||
|       } | ||||
|     }); | ||||
| } | ||||
							
								
								
									
										60
									
								
								assets/js/modules/components/mermaid.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								assets/js/modules/components/mermaid.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | ||||
| /** | ||||
|  * Mermaid-js loader | ||||
|  */ | ||||
|  | ||||
| const MERMAID = 'mermaid'; | ||||
| const themeMapper = Theme.getThemeMapper('default', 'dark'); | ||||
|  | ||||
| function refreshTheme(event) { | ||||
|   if (event.source === window && event.data && event.data.id === Theme.ID) { | ||||
|     // Re-render the SVG › <https://github.com/mermaid-js/mermaid/issues/311#issuecomment-332557344> | ||||
|     const mermaidList = document.getElementsByClassName(MERMAID); | ||||
|  | ||||
|     [...mermaidList].forEach((elem) => { | ||||
|       const svgCode = elem.previousSibling.children.item(0).textContent; | ||||
|       elem.textContent = svgCode; | ||||
|       elem.removeAttribute('data-processed'); | ||||
|     }); | ||||
|  | ||||
|     const newTheme = themeMapper[Theme.visualState]; | ||||
|  | ||||
|     mermaid.initialize({ theme: newTheme }); | ||||
|     mermaid.init(null, `.${MERMAID}`); | ||||
|   } | ||||
| } | ||||
|  | ||||
| function setNode(elem) { | ||||
|   const svgCode = elem.textContent; | ||||
|   const backup = elem.parentElement; | ||||
|   backup.classList.add('d-none'); | ||||
|   // Create mermaid node | ||||
|   const mermaid = document.createElement('pre'); | ||||
|   mermaid.classList.add(MERMAID); | ||||
|   const text = document.createTextNode(svgCode); | ||||
|   mermaid.appendChild(text); | ||||
|   backup.after(mermaid); | ||||
| } | ||||
|  | ||||
| export function loadMermaid() { | ||||
|   if ( | ||||
|     typeof mermaid === 'undefined' || | ||||
|     typeof mermaid.initialize !== 'function' | ||||
|   ) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   const initTheme = themeMapper[Theme.visualState]; | ||||
|  | ||||
|   let mermaidConf = { | ||||
|     theme: initTheme | ||||
|   }; | ||||
|  | ||||
|   const basicList = document.getElementsByClassName('language-mermaid'); | ||||
|   [...basicList].forEach(setNode); | ||||
|  | ||||
|   mermaid.initialize(mermaidConf); | ||||
|  | ||||
|   if (Theme.switchable) { | ||||
|     window.addEventListener('message', refreshTheme); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										15
									
								
								assets/js/modules/components/mode-toggle.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								assets/js/modules/components/mode-toggle.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| /** | ||||
|  * Add listener for theme mode toggle | ||||
|  */ | ||||
|  | ||||
| const $toggle = document.getElementById('mode-toggle'); | ||||
|  | ||||
| export function modeWatcher() { | ||||
|   if (!$toggle) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   $toggle.addEventListener('click', () => { | ||||
|     Theme.flip(); | ||||
|   }); | ||||
| } | ||||
							
								
								
									
										110
									
								
								assets/js/modules/components/search-display.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								assets/js/modules/components/search-display.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,110 @@ | ||||
| /** | ||||
|  * This script make #search-result-wrapper switch to unload or shown automatically. | ||||
|  */ | ||||
|  | ||||
| const btnSbTrigger = document.getElementById('sidebar-trigger'); | ||||
| const btnSearchTrigger = document.getElementById('search-trigger'); | ||||
| const btnCancel = document.getElementById('search-cancel'); | ||||
| const content = document.querySelectorAll('#main-wrapper>.container>.row'); | ||||
| const topbarTitle = document.getElementById('topbar-title'); | ||||
| const search = document.getElementById('search'); | ||||
| const resultWrapper = document.getElementById('search-result-wrapper'); | ||||
| const results = document.getElementById('search-results'); | ||||
| const input = document.getElementById('search-input'); | ||||
| const hints = document.getElementById('search-hints'); | ||||
|  | ||||
| // CSS class names | ||||
| const LOADED = 'd-block'; | ||||
| const UNLOADED = 'd-none'; | ||||
| const FOCUS = 'input-focus'; | ||||
| const FLEX = 'd-flex'; | ||||
|  | ||||
| /* Actions in mobile screens (Sidebar hidden) */ | ||||
| class MobileSearchBar { | ||||
|   static on() { | ||||
|     btnSbTrigger.classList.add(UNLOADED); | ||||
|     topbarTitle.classList.add(UNLOADED); | ||||
|     btnSearchTrigger.classList.add(UNLOADED); | ||||
|     search.classList.add(FLEX); | ||||
|     btnCancel.classList.add(LOADED); | ||||
|   } | ||||
|  | ||||
|   static off() { | ||||
|     btnCancel.classList.remove(LOADED); | ||||
|     search.classList.remove(FLEX); | ||||
|     btnSbTrigger.classList.remove(UNLOADED); | ||||
|     topbarTitle.classList.remove(UNLOADED); | ||||
|     btnSearchTrigger.classList.remove(UNLOADED); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class ResultSwitch { | ||||
|   static resultVisible = false; | ||||
|  | ||||
|   static on() { | ||||
|     if (!this.resultVisible) { | ||||
|       resultWrapper.classList.remove(UNLOADED); | ||||
|       content.forEach((el) => { | ||||
|         el.classList.add(UNLOADED); | ||||
|       }); | ||||
|       this.resultVisible = true; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   static off() { | ||||
|     if (this.resultVisible) { | ||||
|       results.innerHTML = ''; | ||||
|  | ||||
|       if (hints.classList.contains(UNLOADED)) { | ||||
|         hints.classList.remove(UNLOADED); | ||||
|       } | ||||
|  | ||||
|       resultWrapper.classList.add(UNLOADED); | ||||
|       content.forEach((el) => { | ||||
|         el.classList.remove(UNLOADED); | ||||
|       }); | ||||
|       input.textContent = ''; | ||||
|       this.resultVisible = false; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| function isMobileView() { | ||||
|   return btnCancel.classList.contains(LOADED); | ||||
| } | ||||
|  | ||||
| export function displaySearch() { | ||||
|   btnSearchTrigger.addEventListener('click', () => { | ||||
|     MobileSearchBar.on(); | ||||
|     ResultSwitch.on(); | ||||
|     input.focus(); | ||||
|   }); | ||||
|  | ||||
|   btnCancel.addEventListener('click', () => { | ||||
|     MobileSearchBar.off(); | ||||
|     ResultSwitch.off(); | ||||
|   }); | ||||
|  | ||||
|   input.addEventListener('focus', () => { | ||||
|     search.classList.add(FOCUS); | ||||
|   }); | ||||
|  | ||||
|   input.addEventListener('focusout', () => { | ||||
|     search.classList.remove(FOCUS); | ||||
|   }); | ||||
|  | ||||
|   input.addEventListener('input', () => { | ||||
|     if (input.value === '') { | ||||
|       if (isMobileView()) { | ||||
|         hints.classList.remove(UNLOADED); | ||||
|       } else { | ||||
|         ResultSwitch.off(); | ||||
|       } | ||||
|     } else { | ||||
|       ResultSwitch.on(); | ||||
|       if (isMobileView()) { | ||||
|         hints.classList.add(UNLOADED); | ||||
|       } | ||||
|     } | ||||
|   }); | ||||
| } | ||||
							
								
								
									
										36
									
								
								assets/js/modules/components/toc.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								assets/js/modules/components/toc.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | ||||
| import { TocMobile as mobile } from './toc/toc-mobile'; | ||||
| import { TocDesktop as desktop } from './toc/toc-desktop'; | ||||
|  | ||||
| const desktopMode = matchMedia('(min-width: 1200px)'); | ||||
|  | ||||
| function refresh(e) { | ||||
|   if (e.matches) { | ||||
|     if (mobile.popupOpened) { | ||||
|       mobile.hidePopup(); | ||||
|     } | ||||
|  | ||||
|     desktop.refresh(); | ||||
|   } else { | ||||
|     mobile.refresh(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| function init() { | ||||
|   if (document.querySelector('main>article[data-toc="true"]') === null) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   // Avoid create multiple instances of Tocbot. Ref: <https://github.com/tscanlin/tocbot/issues/203> | ||||
|   if (desktopMode.matches) { | ||||
|     desktop.init(); | ||||
|   } else { | ||||
|     mobile.init(); | ||||
|   } | ||||
|  | ||||
|   const $tocWrapper = document.getElementById('toc-wrapper'); | ||||
|   $tocWrapper.classList.remove('invisible'); | ||||
|  | ||||
|   desktopMode.onchange = refresh; | ||||
| } | ||||
|  | ||||
| export { init as initToc }; | ||||
							
								
								
									
										20
									
								
								assets/js/modules/components/toc/toc-desktop.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								assets/js/modules/components/toc/toc-desktop.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| export class TocDesktop { | ||||
|   /* Tocbot options Ref: https://github.com/tscanlin/tocbot#usage */ | ||||
|   static options = { | ||||
|     tocSelector: '#toc', | ||||
|     contentSelector: '.content', | ||||
|     ignoreSelector: '[data-toc-skip]', | ||||
|     headingSelector: 'h2, h3, h4', | ||||
|     orderedList: false, | ||||
|     scrollSmooth: false, | ||||
|     headingsOffset: 16 * 2 // 2rem | ||||
|   }; | ||||
|  | ||||
|   static refresh() { | ||||
|     tocbot.refresh(this.options); | ||||
|   } | ||||
|  | ||||
|   static init() { | ||||
|     tocbot.init(this.options); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										125
									
								
								assets/js/modules/components/toc/toc-mobile.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								assets/js/modules/components/toc/toc-mobile.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,125 @@ | ||||
| /** | ||||
|  * TOC button, topbar and popup for mobile devices | ||||
|  */ | ||||
|  | ||||
| const $tocBar = document.getElementById('toc-bar'); | ||||
| const $soloTrigger = document.getElementById('toc-solo-trigger'); | ||||
| const $triggers = document.getElementsByClassName('toc-trigger'); | ||||
| const $popup = document.getElementById('toc-popup'); | ||||
| const $btnClose = document.getElementById('toc-popup-close'); | ||||
|  | ||||
| const SCROLL_LOCK = 'overflow-hidden'; | ||||
| const CLOSING = 'closing'; | ||||
|  | ||||
| export class TocMobile { | ||||
|   static #invisible = true; | ||||
|   static #barHeight = 16 * 3; // 3rem | ||||
|  | ||||
|   static options = { | ||||
|     tocSelector: '#toc-popup-content', | ||||
|     contentSelector: '.content', | ||||
|     ignoreSelector: '[data-toc-skip]', | ||||
|     headingSelector: 'h2, h3, h4', | ||||
|     orderedList: false, | ||||
|     scrollSmooth: false, | ||||
|     collapseDepth: 4, | ||||
|     headingsOffset: this.#barHeight | ||||
|   }; | ||||
|  | ||||
|   static initBar() { | ||||
|     const observer = new IntersectionObserver( | ||||
|       (entries) => { | ||||
|         entries.forEach((entry) => { | ||||
|           $tocBar.classList.toggle('invisible', entry.isIntersecting); | ||||
|         }); | ||||
|       }, | ||||
|       { rootMargin: `-${this.#barHeight}px 0px 0px 0px` } | ||||
|     ); | ||||
|  | ||||
|     observer.observe($soloTrigger); | ||||
|     this.#invisible = false; | ||||
|   } | ||||
|  | ||||
|   static listenAnchors() { | ||||
|     const $anchors = document.getElementsByClassName('toc-link'); | ||||
|     [...$anchors].forEach((anchor) => { | ||||
|       anchor.onclick = () => this.hidePopup(); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   static refresh() { | ||||
|     if (this.#invisible) { | ||||
|       this.initComponents(); | ||||
|     } | ||||
|     tocbot.refresh(this.options); | ||||
|     this.listenAnchors(); | ||||
|   } | ||||
|  | ||||
|   static get popupOpened() { | ||||
|     return $popup.open; | ||||
|   } | ||||
|  | ||||
|   static showPopup() { | ||||
|     this.lockScroll(true); | ||||
|     $popup.showModal(); | ||||
|     const activeItem = $popup.querySelector('li.is-active-li'); | ||||
|     activeItem.scrollIntoView({ block: 'center' }); | ||||
|   } | ||||
|  | ||||
|   static hidePopup() { | ||||
|     $popup.toggleAttribute(CLOSING); | ||||
|  | ||||
|     $popup.addEventListener( | ||||
|       'animationend', | ||||
|       () => { | ||||
|         $popup.toggleAttribute(CLOSING); | ||||
|         $popup.close(); | ||||
|       }, | ||||
|       { once: true } | ||||
|     ); | ||||
|  | ||||
|     this.lockScroll(false); | ||||
|   } | ||||
|  | ||||
|   static lockScroll(enable) { | ||||
|     document.documentElement.classList.toggle(SCROLL_LOCK, enable); | ||||
|     document.body.classList.toggle(SCROLL_LOCK, enable); | ||||
|   } | ||||
|  | ||||
|   static clickBackdrop(event) { | ||||
|     if ($popup.hasAttribute(CLOSING)) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const rect = event.target.getBoundingClientRect(); | ||||
|     if ( | ||||
|       event.clientX < rect.left || | ||||
|       event.clientX > rect.right || | ||||
|       event.clientY < rect.top || | ||||
|       event.clientY > rect.bottom | ||||
|     ) { | ||||
|       this.hidePopup(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   static initComponents() { | ||||
|     this.initBar(); | ||||
|  | ||||
|     [...$triggers].forEach((trigger) => { | ||||
|       trigger.onclick = () => this.showPopup(); | ||||
|     }); | ||||
|  | ||||
|     $popup.onclick = (e) => this.clickBackdrop(e); | ||||
|     $btnClose.onclick = () => this.hidePopup(); | ||||
|     $popup.oncancel = (e) => { | ||||
|       e.preventDefault(); | ||||
|       this.hidePopup(); | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   static init() { | ||||
|     tocbot.init(this.options); | ||||
|     this.listenAnchors(); | ||||
|     this.initComponents(); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										11
									
								
								assets/js/modules/components/tooltip-loader.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								assets/js/modules/components/tooltip-loader.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| import Tooltip from 'bootstrap/js/src/tooltip'; | ||||
|  | ||||
| export function loadTooptip() { | ||||
|   const tooltipTriggerList = document.querySelectorAll( | ||||
|     '[data-bs-toggle="tooltip"]' | ||||
|   ); | ||||
|  | ||||
|   [...tooltipTriggerList].map( | ||||
|     (tooltipTriggerEl) => new Tooltip(tooltipTriggerEl) | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										3
									
								
								assets/js/modules/layouts.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								assets/js/modules/layouts.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| export { basic } from './layouts/basic'; | ||||
| export { initSidebar } from './layouts/sidebar'; | ||||
| export { initTopbar } from './layouts/topbar'; | ||||
							
								
								
									
										7
									
								
								assets/js/modules/layouts/basic.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								assets/js/modules/layouts/basic.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| import { back2top, loadTooptip, modeWatcher } from '../components'; | ||||
|  | ||||
| export function basic() { | ||||
|   modeWatcher(); | ||||
|   back2top(); | ||||
|   loadTooptip(); | ||||
| } | ||||
							
								
								
									
										19
									
								
								assets/js/modules/layouts/sidebar.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								assets/js/modules/layouts/sidebar.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| const ATTR_DISPLAY = 'sidebar-display'; | ||||
| const $sidebar = document.getElementById('sidebar'); | ||||
| const $trigger = document.getElementById('sidebar-trigger'); | ||||
| const $mask = document.getElementById('mask'); | ||||
|  | ||||
| class SidebarUtil { | ||||
|   static #isExpanded = false; | ||||
|  | ||||
|   static toggle() { | ||||
|     this.#isExpanded = !this.#isExpanded; | ||||
|     document.body.toggleAttribute(ATTR_DISPLAY, this.#isExpanded); | ||||
|     $sidebar.classList.toggle('z-2', this.#isExpanded); | ||||
|     $mask.classList.toggle('d-none', !this.#isExpanded); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function initSidebar() { | ||||
|   $trigger.onclick = $mask.onclick = () => SidebarUtil.toggle(); | ||||
| } | ||||
							
								
								
									
										5
									
								
								assets/js/modules/layouts/topbar.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								assets/js/modules/layouts/topbar.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| import { displaySearch } from '../components/search-display'; | ||||
|  | ||||
| export function initTopbar() { | ||||
|   displaySearch(); | ||||
| } | ||||
							
								
								
									
										138
									
								
								assets/js/modules/theme.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								assets/js/modules/theme.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,138 @@ | ||||
| /** | ||||
|  * Theme management class | ||||
|  * | ||||
|  * To reduce flickering during page load, this script should be loaded synchronously. | ||||
|  */ | ||||
| class Theme { | ||||
|   static #modeKey = 'mode'; | ||||
|   static #modeAttr = 'data-mode'; | ||||
|   static #darkMedia = window.matchMedia('(prefers-color-scheme: dark)'); | ||||
|   static switchable = !document.documentElement.hasAttribute(this.#modeAttr); | ||||
|  | ||||
|   static get DARK() { | ||||
|     return 'dark'; | ||||
|   } | ||||
|  | ||||
|   static get LIGHT() { | ||||
|     return 'light'; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * @returns {string} Theme mode identifier | ||||
|    */ | ||||
|   static get ID() { | ||||
|     return 'theme-mode'; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Gets the current visual state of the theme. | ||||
|    * | ||||
|    * @returns {string} The current visual state, either the mode if it exists, | ||||
|    *                   or the system dark mode state ('dark' or 'light'). | ||||
|    */ | ||||
|   static get visualState() { | ||||
|     if (this.#hasMode) { | ||||
|       return this.#mode; | ||||
|     } else { | ||||
|       return this.#sysDark ? this.DARK : this.LIGHT; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   static get #mode() { | ||||
|     return ( | ||||
|       sessionStorage.getItem(this.#modeKey) || | ||||
|       document.documentElement.getAttribute(this.#modeAttr) | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   static get #isDarkMode() { | ||||
|     return this.#mode === this.DARK; | ||||
|   } | ||||
|  | ||||
|   static get #hasMode() { | ||||
|     return this.#mode !== null; | ||||
|   } | ||||
|  | ||||
|   static get #sysDark() { | ||||
|     return this.#darkMedia.matches; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Maps theme modes to provided values | ||||
|    * @param {string} light Value for light mode | ||||
|    * @param {string} dark Value for dark mode | ||||
|    * @returns {Object} Mapped values | ||||
|    */ | ||||
|   static getThemeMapper(light, dark) { | ||||
|     return { | ||||
|       [this.LIGHT]: light, | ||||
|       [this.DARK]: dark | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Initializes the theme based on system preferences or stored mode | ||||
|    */ | ||||
|   static init() { | ||||
|     if (!this.switchable) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     this.#darkMedia.addEventListener('change', () => { | ||||
|       const lastMode = this.#mode; | ||||
|       this.#clearMode(); | ||||
|  | ||||
|       if (lastMode !== this.visualState) { | ||||
|         this.#notify(); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     if (!this.#hasMode) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (this.#isDarkMode) { | ||||
|       this.#setDark(); | ||||
|     } else { | ||||
|       this.#setLight(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Flips the current theme mode | ||||
|    */ | ||||
|   static flip() { | ||||
|     if (this.#hasMode) { | ||||
|       this.#clearMode(); | ||||
|     } else { | ||||
|       this.#sysDark ? this.#setLight() : this.#setDark(); | ||||
|     } | ||||
|     this.#notify(); | ||||
|   } | ||||
|  | ||||
|   static #setDark() { | ||||
|     document.documentElement.setAttribute(this.#modeAttr, this.DARK); | ||||
|     sessionStorage.setItem(this.#modeKey, this.DARK); | ||||
|   } | ||||
|  | ||||
|   static #setLight() { | ||||
|     document.documentElement.setAttribute(this.#modeAttr, this.LIGHT); | ||||
|     sessionStorage.setItem(this.#modeKey, this.LIGHT); | ||||
|   } | ||||
|  | ||||
|   static #clearMode() { | ||||
|     document.documentElement.removeAttribute(this.#modeAttr); | ||||
|     sessionStorage.removeItem(this.#modeKey); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Notifies other plugins that the theme mode has changed | ||||
|    */ | ||||
|   static #notify() { | ||||
|     window.postMessage({ id: this.ID }, '*'); | ||||
|   } | ||||
| } | ||||
|  | ||||
| Theme.init(); | ||||
|  | ||||
| export default Theme; | ||||
		Reference in New Issue
	
	Block a user
	 geekifan
					geekifan