refactor(assets): streamline decrypt and toc event flow

This commit is contained in:
Cell
2026-07-07 14:52:35 +08:00
parent e58bdd4db5
commit 43c439eee0
20 changed files with 189 additions and 201 deletions
+2 -3
View File
@@ -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)
+8 -3
View File
@@ -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)
+2 -6
View File
@@ -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)
})
}
+88 -40
View File
@@ -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
}
+2 -3
View File
@@ -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)
+2 -3
View File
@@ -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)
+1 -4
View File
@@ -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)
})
})
+2 -3
View File
@@ -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)
}
/**
+2 -5
View File
@@ -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)
+2 -5
View File
@@ -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)
+2 -3
View File
@@ -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)