mirror of
https://github.com/hugo-fixit/FixIt.git
synced 2026-08-24 15:28:57 +00:00
refactor(assets): streamline decrypt and toc event flow
This commit is contained in:
@@ -3,8 +3,8 @@ export interface FixItEventMap {
|
||||
'fixit:switch-theme': { isDark: boolean, mode: string, isChanged: boolean }
|
||||
'fixit:scroll': void
|
||||
'fixit:resize': void
|
||||
'fixit:decrypted': { target: Element }
|
||||
'fixit:partial-decrypted': { target: Element }
|
||||
'fixit:content-decrypted': { target: HTMLElement, isPage: boolean }
|
||||
'fixit:toc-decrypted': { html: string }
|
||||
'fixit:re-encrypt': void
|
||||
'fixit:code-tab-sync': { lang: string, source: HTMLElement }
|
||||
'fixit:sw-update': void
|
||||
|
||||
@@ -45,9 +45,9 @@ export interface CodeService {
|
||||
|
||||
// ─── TocService ───
|
||||
export interface TocService {
|
||||
renderToc: () => void
|
||||
syncTocHeight: () => void
|
||||
syncTocActiveState: () => void
|
||||
initToc: () => void
|
||||
setup: () => void
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Discover `.shortcode-aplayer` nodes and initialize APlayer instances once.
|
||||
* - Re-run initialization after decrypted or partially decrypted content is revealed.
|
||||
* - Re-run initialization when `fixit:content-decrypted` is emitted.
|
||||
* - Keep behavior idempotent through `data-processed` markers.
|
||||
*/
|
||||
import { eventBus } from '../core/event-bus'
|
||||
@@ -24,6 +24,5 @@ function initAPlayer() {
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initAPlayer()
|
||||
eventBus.on('fixit:decrypted', initAPlayer)
|
||||
eventBus.on('fixit:partial-decrypted', initAPlayer)
|
||||
eventBus.on('fixit:content-decrypted', initAPlayer)
|
||||
}, false)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* - Initialize ECharts instances with light/dark theme support.
|
||||
* - Re-render all charts on theme switch.
|
||||
* - Resize charts on window resize events.
|
||||
* - Re-run initialization after decrypted content is revealed.
|
||||
* - Re-run initialization when `fixit:content-decrypted` is emitted.
|
||||
*/
|
||||
import { eventBus } from '../core/event-bus'
|
||||
import { getStagingDOM, isDarkMode, isObjectLiteral } from '../utils'
|
||||
@@ -84,6 +84,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
eventBus.on('fixit:resize', () => {
|
||||
echartsArr.forEach(chart => chart.resize())
|
||||
})
|
||||
eventBus.on('fixit:decrypted', applyEchartsTheme)
|
||||
eventBus.on('fixit:partial-decrypted', ({ detail }) => initEchartsInTarget(detail.target))
|
||||
eventBus.on('fixit:content-decrypted', ({ detail }) => {
|
||||
if (detail.isPage) {
|
||||
applyEchartsTheme()
|
||||
return
|
||||
}
|
||||
initEchartsInTarget(detail.target)
|
||||
})
|
||||
}, false)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Responsibilities:
|
||||
* - Initialize folder expand/collapse interactions for `.file-tree` blocks.
|
||||
* - Recalculate connector line heights when tree visibility/layout changes.
|
||||
* - Sync tree state across tab switches, print preparation, and decrypted content updates.
|
||||
* - Sync tree state across tab switches, print preparation, and `fixit:content-decrypted` updates.
|
||||
*/
|
||||
import type { TabContainerChangedEvent } from '../types'
|
||||
import { eventBus } from '../core/event-bus'
|
||||
@@ -88,11 +88,7 @@ function bindEvents() {
|
||||
}
|
||||
}, false)
|
||||
|
||||
eventBus.on('fixit:decrypted', () => {
|
||||
initFileTree()
|
||||
})
|
||||
|
||||
eventBus.on('fixit:partial-decrypted', ({ detail }) => {
|
||||
eventBus.on('fixit:content-decrypted', ({ detail }) => {
|
||||
initFileTree(detail.target)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* Encrypted content decryptor for FixIt pages and shortcodes.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Validate password input and decrypt AES-256-GCM payloads into target containers.
|
||||
* - Validate password input and decrypt AES-256-GCM payloads.
|
||||
* - Support both full-page and shortcode-scoped encrypted blocks.
|
||||
* - Persist and validate page-level decrypt cache with expiration.
|
||||
* - Emit and react to FixIt events for decrypted/partial-decrypted/reset flows.
|
||||
* - Emit FixIt events for content/TOC rendering and re-encrypt flows.
|
||||
*
|
||||
* Encrypted payloads are stored in `<template data-password="..." data-cipher="...">` elements.
|
||||
* Using `<template>` prevents the browser from rendering inner content (mermaid, echarts, etc.).
|
||||
@@ -43,6 +43,33 @@ class FixItDecryptor {
|
||||
customElements.get('fixit-encryptor') || customElements.define('fixit-encryptor', class extends HTMLElement {})
|
||||
}
|
||||
|
||||
/** Toggle encrypted/decrypted visibility helper classes in the given scope. */
|
||||
#toggleClass(container: Element | Document, show: boolean): void {
|
||||
const fromClass = show ? 'encrypted-hidden' : 'decrypted-shown'
|
||||
const toClass = show ? 'decrypted-shown' : 'encrypted-hidden'
|
||||
container.querySelectorAll(`.${fromClass}`).forEach(($element: Element) => {
|
||||
$element.classList.replace(fromClass, toClass)
|
||||
})
|
||||
}
|
||||
|
||||
/** Copy TOC HTML to all TOC containers. */
|
||||
#renderToc(html: string): void {
|
||||
const $template = document.createElement('template')
|
||||
$template.innerHTML = html
|
||||
const $tocCore = $template.content.querySelector('#TableOfContents')
|
||||
if (!$tocCore)
|
||||
return
|
||||
for (const id of ['toc-content-static', 'toc-content-auto', 'toc-content-drawer']) {
|
||||
const $container = document.getElementById(id)
|
||||
if (!$container)
|
||||
continue
|
||||
$container.textContent = ''
|
||||
const $clone = $tocCore.cloneNode(true) as HTMLElement
|
||||
$clone.removeAttribute('id')
|
||||
$container.appendChild($clone)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a v2 payload using PBKDF2 key derivation.
|
||||
* Format: base64(salt).base64(iv).base64(ciphertext+tag)
|
||||
@@ -76,6 +103,11 @@ class FixItDecryptor {
|
||||
return new TextDecoder().decode(plainBuffer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute SHA-256 hex digest for user input.
|
||||
* @param input - Raw password input.
|
||||
* @returns Lowercase hex SHA-256 digest.
|
||||
*/
|
||||
async #sha256Hex(input: string): Promise<string> {
|
||||
const bytes = new TextEncoder().encode(input)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', bytes)
|
||||
@@ -100,6 +132,45 @@ class FixItDecryptor {
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt TOC template content and refill TOC containers directly.
|
||||
* The template itself stays encrypted so re-encrypt can restore the initial state.
|
||||
* @param passwordHash - SHA-256 hash for AES key derivation.
|
||||
*/
|
||||
async #decryptToc(passwordHash: string): Promise<void> {
|
||||
const $tocTemplate = document.querySelector<HTMLTemplateElement>('template[data-toc]')
|
||||
if (!$tocTemplate)
|
||||
return
|
||||
try {
|
||||
const cipher = $tocTemplate.dataset.cipher
|
||||
const payload = $tocTemplate.innerHTML
|
||||
|
||||
let html: string
|
||||
if (!cipher) {
|
||||
// Dev mode: no post-build encryption, TOC is plaintext
|
||||
html = payload
|
||||
}
|
||||
else if (cipher === 'aes-256-gcm-v2') {
|
||||
html = await this.#decryptV2(payload, passwordHash)
|
||||
}
|
||||
else {
|
||||
throw new Error(`Unsupported cipher: ${cipher}`)
|
||||
}
|
||||
|
||||
this.#renderToc(html)
|
||||
eventBus.emit('fixit:toc-decrypted', { html })
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[FixItDecryptor] Failed to restore TOC:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt template payload and write plaintext into the target container.
|
||||
* @param $template - Encrypted content template element.
|
||||
* @param $target - Target container that should receive decrypted content.
|
||||
* @param passwordHash - SHA-256 hash for AES key derivation.
|
||||
*/
|
||||
async #decryptContent($template: HTMLTemplateElement, $target: HTMLElement, passwordHash: string): Promise<void> {
|
||||
try {
|
||||
const cipher = $template.dataset.cipher
|
||||
@@ -119,6 +190,9 @@ class FixItDecryptor {
|
||||
|
||||
$target.innerHTML = html
|
||||
$template.parentElement!.classList.add('decrypted')
|
||||
const isPage = $target.id === 'content'
|
||||
this.#toggleClass(isPage ? document : $target, true)
|
||||
eventBus.emit('fixit:content-decrypted', { target: $target, isPage })
|
||||
}
|
||||
catch (err) {
|
||||
const $encryptor = $template.parentElement!
|
||||
@@ -128,10 +202,6 @@ class FixItDecryptor {
|
||||
}
|
||||
return console.error(err)
|
||||
}
|
||||
if ($target.id === 'content')
|
||||
eventBus.emit('fixit:decrypted', { target: $target })
|
||||
else
|
||||
eventBus.emit('fixit:partial-decrypted', { target: $target })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -185,10 +255,7 @@ class FixItDecryptor {
|
||||
*/
|
||||
init({ all, shortcode }: { all?: boolean, shortcode?: boolean }): void {
|
||||
if (shortcode) {
|
||||
eventBus.on('fixit:decrypted', ({ detail }) => {
|
||||
this.initShortcodes(detail.target)
|
||||
})
|
||||
eventBus.on('fixit:partial-decrypted', ({ detail }) => {
|
||||
eventBus.on('fixit:content-decrypted', ({ detail }) => {
|
||||
this.initShortcodes(detail.target)
|
||||
})
|
||||
}
|
||||
@@ -200,27 +267,7 @@ class FixItDecryptor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the TOC template content in place.
|
||||
* After decryption, TocModule.initToc() (via fixit:decrypted event) copies it to containers.
|
||||
* @param passwordHash - SHA-256 hash for AES key derivation.
|
||||
*/
|
||||
async #restoreToc(passwordHash: string): Promise<void> {
|
||||
const $tocTemplate = document.querySelector<HTMLTemplateElement>('template[data-toc][data-cipher]')
|
||||
if (!$tocTemplate)
|
||||
return
|
||||
try {
|
||||
const cipher = $tocTemplate.dataset.cipher
|
||||
const payload = $tocTemplate.innerHTML
|
||||
if (cipher === 'aes-256-gcm-v2')
|
||||
$tocTemplate.innerHTML = await this.#decryptV2(payload, passwordHash)
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[FixItDecryptor] Failed to restore TOC:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/** Initialize whole-page decryption with cache validation and encrypt/re-encrypt buttons. */
|
||||
/** Initialize whole-page decryption with cache validation and input handlers. */
|
||||
initPage(): void {
|
||||
this.validateCache()
|
||||
const $encryptor = document.querySelector<HTMLElement>('article > fixit-encryptor')!
|
||||
@@ -236,7 +283,7 @@ class FixItDecryptor {
|
||||
sha256: passwordHash,
|
||||
}),
|
||||
)
|
||||
await this.#restoreToc(passwordHash)
|
||||
await this.#decryptToc(passwordHash)
|
||||
await this.#decryptContent($template, $content, passwordHash)
|
||||
}).catch(console.error)
|
||||
}
|
||||
@@ -253,11 +300,17 @@ class FixItDecryptor {
|
||||
decryptorHandler()
|
||||
})
|
||||
|
||||
// Only for full-page decryption: re-encrypt button to clear cache and reset content
|
||||
// Re-encrypt button only orchestrates state/cache reset; modules handle DOM cleanup.
|
||||
const $reEncryptBtn = $encryptor.querySelector<HTMLElement>('.fixit-encryptor-btn')
|
||||
$reEncryptBtn?.addEventListener('click', (e) => {
|
||||
e.preventDefault()
|
||||
window.CellTooltip?.getOrCreateInstance($reEncryptBtn).dispose()
|
||||
for (const id of ['toc-content-static', 'toc-content-auto', 'toc-content-drawer']) {
|
||||
const $el = document.getElementById(id)
|
||||
if ($el) {
|
||||
$el.textContent = ''
|
||||
}
|
||||
}
|
||||
$content.animate(
|
||||
[
|
||||
{ opacity: 1, transform: 'scaleY(1)', transformOrigin: 'top' },
|
||||
@@ -269,13 +322,8 @@ class FixItDecryptor {
|
||||
$content.style.opacity = ''
|
||||
$content.style.transform = ''
|
||||
})
|
||||
for (const id of ['toc-content-static', 'toc-content-auto', 'toc-content-drawer']) {
|
||||
const $el = document.getElementById(id)
|
||||
if ($el?.querySelector('nav')) {
|
||||
$el.textContent = ''
|
||||
}
|
||||
}
|
||||
$encryptor.classList.remove('decrypted')
|
||||
this.#toggleClass(document, false)
|
||||
window.localStorage?.removeItem(`fixit-decryptor/#${location.pathname}`)
|
||||
eventBus.emit('fixit:re-encrypt')
|
||||
})
|
||||
@@ -330,7 +378,7 @@ class FixItDecryptor {
|
||||
return this
|
||||
}
|
||||
// Use sha256 hash for AES key derivation (not the verification hash)
|
||||
void this.#restoreToc(cachedStat.sha256)
|
||||
void this.#decryptToc(cachedStat.sha256)
|
||||
.then(() => this.#decryptContent($template, $content, cachedStat.sha256))
|
||||
return this
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Initialize lightGallery for page image zoom and thumbnails.
|
||||
* - Re-initialize on decrypted or partially decrypted content.
|
||||
* - Re-initialize when `fixit:content-decrypted` is emitted.
|
||||
*/
|
||||
import { eventBus } from '../core/event-bus'
|
||||
|
||||
@@ -36,6 +36,5 @@ function initLightGallery() {
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initLightGallery()
|
||||
eventBus.on('fixit:decrypted', initLightGallery)
|
||||
eventBus.on('fixit:partial-decrypted', initLightGallery)
|
||||
eventBus.on('fixit:content-decrypted', initLightGallery)
|
||||
}, false)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Responsibilities:
|
||||
* - Initialize Mapbox GL maps with controls and optional markers.
|
||||
* - Apply light/dark style on theme switch.
|
||||
* - Re-run initialization after decrypted content is revealed.
|
||||
* - Re-run initialization when `fixit:content-decrypted` is emitted.
|
||||
*/
|
||||
import { eventBus } from '../core/event-bus'
|
||||
import { isDarkMode } from '../utils'
|
||||
@@ -83,6 +83,5 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
mapbox.addControl(new window.MapboxLanguage())
|
||||
})
|
||||
})
|
||||
eventBus.on('fixit:decrypted', () => initMapbox())
|
||||
eventBus.on('fixit:partial-decrypted', ({ detail }) => initMapbox(detail.target))
|
||||
eventBus.on('fixit:content-decrypted', ({ detail }) => initMapbox(detail.target))
|
||||
}, false)
|
||||
|
||||
@@ -68,10 +68,7 @@ function initMathJax(el?: Element) {
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
bootstrapMathJax()
|
||||
eventBus.on('fixit:decrypted', () => {
|
||||
initMathJax()
|
||||
})
|
||||
eventBus.on('fixit:partial-decrypted', ({ detail }) => {
|
||||
eventBus.on('fixit:content-decrypted', ({ detail }) => {
|
||||
initMathJax(detail.target)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* - Bind pan/zoom behavior and synchronize transforms across light/dark layers
|
||||
* during theme switches.
|
||||
* - Wire diagram tab controls (diagram/code switch, zoom/reset/download actions).
|
||||
* - React to FixIt events (theme switch, decrypted content, partial decrypted content)
|
||||
* - React to FixIt events (theme switch and `fixit:content-decrypted`)
|
||||
* and re-observe/re-render affected containers when context changes.
|
||||
*
|
||||
* Public entrypoints:
|
||||
@@ -793,8 +793,7 @@ function bindGlobalEventsOnce(): void {
|
||||
bindTabContainerChanged()
|
||||
|
||||
initMermaid()
|
||||
eventBus.on('fixit:decrypted', initMermaid)
|
||||
eventBus.on('fixit:partial-decrypted', initMermaid)
|
||||
eventBus.on('fixit:content-decrypted', initMermaid)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Responsibilities:
|
||||
* - Automatically add spacing between CJK (Chinese, Japanese, Korean) and ASCII characters.
|
||||
* - Support both full-page spacing and selector-based spacing.
|
||||
* - Re-run spacing after decrypted or partially decrypted content is revealed.
|
||||
* - Re-run spacing when `fixit:content-decrypted` is emitted.
|
||||
*/
|
||||
import { eventBus } from '../core/event-bus'
|
||||
|
||||
@@ -30,10 +30,7 @@ function initPangu(target?: Element) {
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initPangu()
|
||||
eventBus.on('fixit:decrypted', () => {
|
||||
initPangu()
|
||||
})
|
||||
eventBus.on('fixit:partial-decrypted', ({ detail }) => {
|
||||
eventBus.on('fixit:content-decrypted', ({ detail }) => {
|
||||
initPangu(detail.target)
|
||||
})
|
||||
}, false)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Parse emoji shortcodes into Twemoji images when enabled.
|
||||
* - Re-run parsing after decrypted or partially decrypted content is revealed.
|
||||
* - Re-run parsing when `fixit:content-decrypted` is emitted.
|
||||
*/
|
||||
import { eventBus } from '../core/event-bus'
|
||||
|
||||
@@ -14,10 +14,7 @@ function initTwemoji(target: Element | Document = document) {
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initTwemoji()
|
||||
eventBus.on('fixit:decrypted', () => {
|
||||
initTwemoji()
|
||||
})
|
||||
eventBus.on('fixit:partial-decrypted', ({ detail }) => {
|
||||
eventBus.on('fixit:content-decrypted', ({ detail }) => {
|
||||
initTwemoji(detail.target)
|
||||
})
|
||||
}, false)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Initialize TypeIt typewriter instances, grouped and chained by data attributes.
|
||||
* - Re-run initialization after decrypted content is revealed.
|
||||
* - Re-run initialization when `fixit:content-decrypted` is emitted.
|
||||
*/
|
||||
import { eventBus } from '../core/event-bus'
|
||||
import { getStagingDOM } from '../utils'
|
||||
@@ -66,6 +66,5 @@ function initTypeit(target: Element | Document = document) {
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initTypeit()
|
||||
eventBus.on('fixit:decrypted', () => initTypeit())
|
||||
eventBus.on('fixit:partial-decrypted', ({ detail }) => initTypeit(detail.target))
|
||||
eventBus.on('fixit:content-decrypted', ({ detail }) => initTypeit(detail.target))
|
||||
}, false)
|
||||
|
||||
@@ -187,10 +187,7 @@ export class ContentModule implements ContentService {
|
||||
setup() {
|
||||
this.initContent()
|
||||
this.initSVGIcon()
|
||||
eventBus.on('fixit:decrypted', ({ detail }) => {
|
||||
this.initContent(detail.target)
|
||||
})
|
||||
eventBus.on('fixit:partial-decrypted', ({ detail }) => {
|
||||
eventBus.on('fixit:content-decrypted', ({ detail }) => {
|
||||
this.initContent(detail.target)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,45 +1,20 @@
|
||||
import type { CoreService, EncryptionService } from '../core/tokens'
|
||||
import { eventBus } from '../core/event-bus'
|
||||
|
||||
/**
|
||||
* Encryption module — page decryption via FixItDecryptor and encrypted content toggling.
|
||||
* Encryption module — bootstrap page/shortcode decryption via FixItDecryptor.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Initialize FixItDecryptor for full-page and shortcode-scoped decryption.
|
||||
* - Toggle visibility of encrypted content sections.
|
||||
*/
|
||||
export class EncryptionModule implements EncryptionService {
|
||||
constructor(private readonly core: CoreService) {}
|
||||
|
||||
/**
|
||||
* Toggle between encrypted-hidden and decrypted-shown classes.
|
||||
* @param container - The root element containing encrypted elements.
|
||||
* @param show - `true` to show decrypted content, `false` to hide.
|
||||
*/
|
||||
#toggleEncryptedClass(container: Element | Document, show: boolean) {
|
||||
const fromClass = show ? 'encrypted-hidden' : 'decrypted-shown'
|
||||
const toClass = show ? 'decrypted-shown' : 'encrypted-hidden'
|
||||
container.querySelectorAll(`.${fromClass}`).forEach(($element: Element) => {
|
||||
$element.classList.replace(fromClass, toClass)
|
||||
})
|
||||
}
|
||||
|
||||
/** Initialize the FixItDecryptor and wire up decryption/re-encryption events. */
|
||||
/** Initialize FixItDecryptor with encryption config. */
|
||||
setup() {
|
||||
if (!this.core.config.encryption || !window.FixItDecryptor)
|
||||
return
|
||||
const decryptor = new window.FixItDecryptor()
|
||||
|
||||
eventBus.on('fixit:decrypted', () => {
|
||||
this.#toggleEncryptedClass(document, true)
|
||||
})
|
||||
eventBus.on('fixit:partial-decrypted', ({ detail }) => {
|
||||
this.#toggleEncryptedClass(detail.target, true)
|
||||
})
|
||||
eventBus.on('fixit:re-encrypt', () => {
|
||||
this.#toggleEncryptedClass(document, false)
|
||||
})
|
||||
|
||||
decryptor.init(this.core.config.encryption)
|
||||
}
|
||||
}
|
||||
|
||||
+68
-84
@@ -1,11 +1,15 @@
|
||||
import type { TocService } from '../core/tokens'
|
||||
import { eventBus } from '../core/event-bus'
|
||||
import { animateCSS, isTocStatic } from '../utils'
|
||||
import { animateCSS, isMobile, isTocStatic } from '../utils'
|
||||
|
||||
const TOC_CONTAINER_IDS = ['toc-content-auto', 'toc-content-static', 'toc-content-drawer'] as const
|
||||
|
||||
/**
|
||||
* Table of Contents module — TOC scroll tracking, active state sync, and dialog.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Render TOC from template and sync layout state.
|
||||
* - Clear TOC containers on `fixit:re-encrypt`.
|
||||
* - Move TOC node to the correct container (static, auto, or drawer) on init.
|
||||
* - Track scroll position and highlight the active heading in all TOC containers.
|
||||
* - Initialize mobile TOC drawer dialog and its open/close handlers.
|
||||
@@ -14,46 +18,33 @@ import { animateCSS, isTocStatic } from '../utils'
|
||||
export class TocModule implements TocService {
|
||||
private activeTocId: string | null = null
|
||||
|
||||
/** Get the pixel height of the currently visible sticky header. */
|
||||
getVisibleHeaderOffset(): number {
|
||||
const $desktopHeader = document.getElementById('header-desktop')
|
||||
const $mobileHeader = document.getElementById('header-mobile')
|
||||
const $header = [$desktopHeader, $mobileHeader].find($el => $el && window.getComputedStyle($el).display !== 'none')
|
||||
if (!$header)
|
||||
return 0
|
||||
const isDesktop = $header.id === 'header-desktop'
|
||||
const headerMode = isDesktop ? document.body.dataset.headerDesktop : document.body.dataset.headerMobile
|
||||
if (!['sticky', 'auto'].includes(headerMode!))
|
||||
return 0
|
||||
if (headerMode === 'auto' && $header.classList.contains('header__fadeOutUp'))
|
||||
return 0
|
||||
return $header.offsetHeight
|
||||
}
|
||||
|
||||
/** Get the pixel height of the breadcrumb container. */
|
||||
getBreadcrumbHeight(): number {
|
||||
return document.querySelector<HTMLElement>('.breadcrumb-container')?.offsetHeight || 0
|
||||
}
|
||||
|
||||
/** Get the combined vertical offset used to determine the active TOC heading. */
|
||||
getTocIndexOffset(): number {
|
||||
return 20 + this.getVisibleHeaderOffset() + this.getBreadcrumbHeight()
|
||||
}
|
||||
|
||||
/** Get all heading elements that have an `id` attribute. */
|
||||
getTocHeadingElements(): HTMLElement[] {
|
||||
return Array.from(document.querySelectorAll<HTMLElement>('.heading-element[id]'))
|
||||
/** Get all TOC content containers (auto, static, and drawer). */
|
||||
#getTocContainers(): HTMLElement[] {
|
||||
return TOC_CONTAINER_IDS
|
||||
.map(id => document.getElementById(id))
|
||||
.filter(Boolean) as HTMLElement[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which heading is currently active based on scroll position.
|
||||
* @param $headingElements - Array of heading elements with `id` attributes.
|
||||
* @param indexOffset - Vertical offset from the top for the active threshold.
|
||||
* @returns The active heading element, or `null` if none found.
|
||||
*/
|
||||
getActiveTocHeading($headingElements: HTMLElement[], indexOffset = this.getTocIndexOffset()): HTMLElement | null {
|
||||
#getActiveTocHeading(): HTMLElement | null {
|
||||
const $headingElements = Array.from(document.querySelectorAll<HTMLElement>('.heading-element[id]'))
|
||||
if (!$headingElements.length)
|
||||
return null
|
||||
const headerOffset = (() => {
|
||||
const headerId = isMobile() ? 'header-mobile' : 'header-desktop'
|
||||
const $header = document.getElementById(headerId)
|
||||
const headerMode = document.body.getAttribute(`data-${headerId}`)
|
||||
if (!$header || window.getComputedStyle($header).display === 'none')
|
||||
return 0
|
||||
const shouldApplyOffset = headerMode === 'sticky' || (headerMode === 'auto' && !$header.classList.contains('header__fadeOutUp'))
|
||||
return shouldApplyOffset ? $header.offsetHeight : 0
|
||||
})()
|
||||
const breadcrumbOffset = document.querySelector<HTMLElement>('.breadcrumb-container')?.offsetHeight || 0
|
||||
const indexOffset = 20 + headerOffset + breadcrumbOffset
|
||||
|
||||
const threshold = window.scrollY + indexOffset + 1
|
||||
let $activeHeading = $headingElements[0]
|
||||
for (const $heading of $headingElements) {
|
||||
@@ -68,22 +59,13 @@ export class TocModule implements TocService {
|
||||
return $activeHeading
|
||||
}
|
||||
|
||||
/** Get all TOC root containers (static, auto, and drawer). */
|
||||
getTocRoots(): HTMLElement[] {
|
||||
return [
|
||||
document.querySelector<HTMLElement>('#toc-content-auto > nav'),
|
||||
document.querySelector<HTMLElement>('#toc-content-static > nav'),
|
||||
document.querySelector<HTMLElement>('#toc-content-drawer > nav'),
|
||||
].filter(Boolean) as HTMLElement[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the TOC link that points to the given heading id.
|
||||
* @param $tocRoot - The TOC root container element.
|
||||
* @param id - The heading id (without `#`).
|
||||
* @returns The matching anchor element, or `null`.
|
||||
*/
|
||||
getTocLinkById($tocRoot: HTMLElement, id: string): HTMLAnchorElement | null {
|
||||
#getTocLinkById($tocRoot: HTMLElement, id: string): HTMLAnchorElement | null {
|
||||
if (!$tocRoot || !id)
|
||||
return null
|
||||
const targetHash = `#${id}`
|
||||
@@ -95,7 +77,7 @@ export class TocModule implements TocService {
|
||||
* @param $tocRoot - The TOC root container element.
|
||||
* @param activeId - The id of the currently active heading.
|
||||
*/
|
||||
applyTocActiveState($tocRoot: HTMLElement, activeId: string) {
|
||||
#applyTocActiveState($tocRoot: HTMLElement, activeId: string) {
|
||||
if (!$tocRoot)
|
||||
return
|
||||
$tocRoot.querySelectorAll('a[href^="#"]').forEach(($tocLink: Element) => {
|
||||
@@ -104,7 +86,7 @@ export class TocModule implements TocService {
|
||||
$tocRoot.querySelectorAll('li').forEach(($tocLi: Element) => {
|
||||
$tocLi.classList.remove('has-active')
|
||||
})
|
||||
const $activeLink = this.getTocLinkById($tocRoot, activeId)
|
||||
const $activeLink = this.#getTocLinkById($tocRoot, activeId)
|
||||
if (!$activeLink)
|
||||
return
|
||||
$activeLink.classList.add('active')
|
||||
@@ -121,8 +103,8 @@ export class TocModule implements TocService {
|
||||
* @param activeId - The id of the currently active heading.
|
||||
* @param $scrollContainer - The scrollable container (defaults to `$tocRoot`).
|
||||
*/
|
||||
scrollActiveTocLinkIntoView($tocRoot: HTMLElement, activeId: string, $scrollContainer: HTMLElement = $tocRoot) {
|
||||
const $activeLink = this.getTocLinkById($tocRoot, activeId)
|
||||
#scrollActiveTocLinkIntoView($tocRoot: HTMLElement, activeId: string, $scrollContainer: HTMLElement = $tocRoot) {
|
||||
const $activeLink = this.#getTocLinkById($tocRoot, activeId)
|
||||
if (!$activeLink || !$scrollContainer)
|
||||
return
|
||||
const containerRect = $scrollContainer.getBoundingClientRect()
|
||||
@@ -149,14 +131,15 @@ export class TocModule implements TocService {
|
||||
|
||||
/** Sync the active heading highlight across all TOC containers. */
|
||||
syncTocActiveState() {
|
||||
const $headingElements = this.getTocHeadingElements()
|
||||
const $activeHeading = this.getActiveTocHeading($headingElements)
|
||||
const $activeHeading = this.#getActiveTocHeading()
|
||||
if (!$activeHeading?.id)
|
||||
return
|
||||
const activeId = $activeHeading.id
|
||||
const $tocRoots = this.getTocRoots()
|
||||
const $tocRoots = this.#getTocContainers()
|
||||
.map($container => $container.querySelector<HTMLElement>('nav'))
|
||||
.filter(Boolean) as HTMLElement[]
|
||||
$tocRoots.forEach(($tocRoot) => {
|
||||
this.applyTocActiveState($tocRoot, activeId)
|
||||
this.#applyTocActiveState($tocRoot, activeId)
|
||||
})
|
||||
if (this.activeTocId !== activeId) {
|
||||
this.activeTocId = activeId
|
||||
@@ -164,45 +147,42 @@ export class TocModule implements TocService {
|
||||
const $autoTocRoot = document.querySelector<HTMLElement>('#toc-content-auto > nav')
|
||||
const $autoTocContainer = document.getElementById('toc-content-auto')
|
||||
if ($autoTocRoot && $autoTocContainer) {
|
||||
this.scrollActiveTocLinkIntoView($autoTocRoot, activeId, $autoTocContainer)
|
||||
this.#scrollActiveTocLinkIntoView($autoTocRoot, activeId, $autoTocContainer)
|
||||
}
|
||||
}
|
||||
if ((document.getElementById('toc-dialog') as HTMLDialogElement)?.open) {
|
||||
const $dialogTocRoot = document.querySelector<HTMLElement>('#toc-content-drawer > nav')!
|
||||
this.scrollActiveTocLinkIntoView($dialogTocRoot, activeId, $dialogTocRoot)
|
||||
this.#scrollActiveTocLinkIntoView($dialogTocRoot, activeId, $dialogTocRoot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Sync TOC layout state: drawer button visibility, height, and active heading. */
|
||||
syncTocLayout() {
|
||||
#syncTocLayout() {
|
||||
document.querySelector<HTMLElement>('#toc-drawer-button')?.classList.toggle('hidden', !isTocStatic())
|
||||
this.activeTocId = null
|
||||
this.syncTocHeight()
|
||||
this.syncTocActiveState()
|
||||
}
|
||||
|
||||
/** Initialize TOC layout: read from `<template data-toc>`, copy to the correct container and dialog. */
|
||||
initToc() {
|
||||
/** Render TOC from `<template data-toc>` into static/auto/drawer containers. */
|
||||
renderToc() {
|
||||
const $tocTemplate = document.querySelector<HTMLTemplateElement>('template[data-toc]')
|
||||
if ($tocTemplate?.dataset.password)
|
||||
return
|
||||
const $tocCore = $tocTemplate?.content.querySelector('#TableOfContents')
|
||||
if (!$tocTemplate || !$tocCore)
|
||||
return
|
||||
|
||||
// Copy TOC to target containers
|
||||
const targets = ['toc-content-static', 'toc-content-auto', 'toc-content-drawer']
|
||||
for (const id of targets) {
|
||||
const $container = document.getElementById(id)
|
||||
if ($container && !$container.querySelector('nav')) {
|
||||
const $clone = $tocCore.cloneNode(true) as HTMLElement
|
||||
$clone.removeAttribute('id')
|
||||
$container.appendChild($clone)
|
||||
}
|
||||
for (const $container of this.#getTocContainers()) {
|
||||
$container.textContent = ''
|
||||
const $clone = $tocCore.cloneNode(true) as HTMLElement
|
||||
$clone.removeAttribute('id')
|
||||
$container.appendChild($clone)
|
||||
}
|
||||
}
|
||||
|
||||
/** Bind the TOC title click handler for show/hide toggle. */
|
||||
initTocListener() {
|
||||
/** Bind the `toc-auto` title click handler for show/hide toggle. */
|
||||
#initTocToggle() {
|
||||
const $toc = document.getElementById('toc-auto')!
|
||||
const $tocContentAuto = document.getElementById('toc-content-auto')!
|
||||
document.querySelector<HTMLElement>('#toc-auto>.toc-title')?.addEventListener('click', () => {
|
||||
@@ -223,7 +203,7 @@ export class TocModule implements TocService {
|
||||
}
|
||||
|
||||
/** Initialize the mobile TOC drawer dialog and its open/close handlers. */
|
||||
initTocDialogLink() {
|
||||
#initTocDrawerLinkClose() {
|
||||
const dialog = document.querySelector<HTMLDialogElement>('#toc-dialog')
|
||||
if (!dialog)
|
||||
return
|
||||
@@ -236,7 +216,7 @@ export class TocModule implements TocService {
|
||||
}
|
||||
|
||||
/** Initialize the mobile TOC drawer dialog and its open/close handlers. */
|
||||
initTocDialog() {
|
||||
#initTocDrawer() {
|
||||
const dialog = document.querySelector<HTMLDialogElement>('#toc-dialog')
|
||||
const openButton = document.querySelector<HTMLElement>('#toc-drawer-button')
|
||||
if (!dialog || !openButton)
|
||||
@@ -249,7 +229,7 @@ export class TocModule implements TocService {
|
||||
this.syncTocHeight()
|
||||
this.syncTocActiveState()
|
||||
const $dialogTocRoot = document.querySelector<HTMLElement>('#toc-content-drawer > nav')!
|
||||
this.scrollActiveTocLinkIntoView($dialogTocRoot, this.activeTocId!, $dialogTocRoot)
|
||||
this.#scrollActiveTocLinkIntoView($dialogTocRoot, this.activeTocId!, $dialogTocRoot)
|
||||
;(document.activeElement as HTMLElement)?.blur()
|
||||
})
|
||||
dialog.addEventListener('close', () => {
|
||||
@@ -257,8 +237,8 @@ export class TocModule implements TocService {
|
||||
})
|
||||
}
|
||||
|
||||
/** Clone heading-mark nodes to detach APlayer event listeners. */
|
||||
fixTocScroll() {
|
||||
/** Reset heading-mark nodes to fix the APlayer-caused anchor click issue. */
|
||||
#resetHeadingClicks() {
|
||||
if (typeof window.APlayer === 'function') {
|
||||
document.querySelectorAll('.heading-mark').forEach(($headingMark: Element) => {
|
||||
const $newHeadingMark = $headingMark.cloneNode(true)
|
||||
@@ -269,18 +249,22 @@ export class TocModule implements TocService {
|
||||
|
||||
/** Initialize all TOC components and register event listeners. */
|
||||
setup() {
|
||||
this.initToc()
|
||||
this.syncTocLayout()
|
||||
this.initTocListener()
|
||||
this.initTocDialog()
|
||||
this.initTocDialogLink()
|
||||
this.fixTocScroll()
|
||||
eventBus.on('fixit:resize', () => this.syncTocLayout())
|
||||
this.renderToc()
|
||||
this.#initTocToggle()
|
||||
this.#syncTocLayout()
|
||||
this.#initTocDrawer()
|
||||
this.#initTocDrawerLinkClose()
|
||||
this.#resetHeadingClicks()
|
||||
eventBus.on('fixit:resize', () => this.#syncTocLayout())
|
||||
eventBus.on('fixit:scroll', () => this.syncTocActiveState())
|
||||
eventBus.on('fixit:decrypted', () => {
|
||||
this.initToc()
|
||||
this.syncTocLayout()
|
||||
this.initTocDialogLink()
|
||||
eventBus.on('fixit:content-decrypted', ({ detail }) => {
|
||||
if (detail.isPage) {
|
||||
this.#syncTocLayout()
|
||||
this.#initTocDrawerLinkClose()
|
||||
}
|
||||
})
|
||||
eventBus.on('fixit:re-encrypt', () => {
|
||||
this.#syncTocLayout()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +151,6 @@
|
||||
text-indent: -0.8em;
|
||||
list-style: none;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
max-height: 60vh;
|
||||
@include scrollbar-width(none, 0);
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
gap: 0.25em;
|
||||
padding: 0.2em 0.5em;
|
||||
user-select: none;
|
||||
|
||||
|
||||
> i.fa-layer-group {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -52,7 +52,6 @@
|
||||
text-indent: -0.8em;
|
||||
list-style: none;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
max-height: 60vh;
|
||||
@include scrollbar-width(none, 0);
|
||||
|
||||
|
||||
@@ -125,7 +125,6 @@
|
||||
|
||||
.toc-content {
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
max-height: fi-var(toc-content-max-height, 90dvh);
|
||||
@include scrollbar-width(none, 0);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{- hugo.Store.Set "version" "v1.0.0-mr84fuvo" -}}
|
||||
{{- hugo.Store.Set "version" "v1.0.0-mraakpxu" -}}
|
||||
{{- .Store.Set "this" dict -}}
|
||||
|
||||
{{- partial "init/detection-env.html" . -}}
|
||||
|
||||
Reference in New Issue
Block a user