import type { CodeService } from '../core/tokens' import { eventBus } from '../core/event-bus' import { animateCSS, createCopyText, downloadAsFile, flashCopiedTooltip, getStagingDOM } from '../utils' /** * Code module — code block interactions: copy, download, fullscreen, tabs, and line numbers. * * Responsibilities: * - Wrap code blocks with action buttons (copy, download, fullscreen, line numbers). * - Initialize code tab groups and sync selection across related blocks. * - Copy diagram source (Mermaid, ECharts) from code blocks. */ const CellTooltip = window.CellTooltip const copyText = createCopyText() export class CodeModule implements CodeService { #fullscreenAbort: AbortController | undefined constructor() {} /** * Attach copy-to-clipboard behaviour to a code block. * @param codeBlock - The `.code-block` container element. * @param codePreEl - The `
` element containing the code text.
*/
initCopyCode(codeBlock: HTMLElement, codePreEl: HTMLElement) {
const copyBtn = codeBlock.dataset.mode === 'classic'
? codeBlock.querySelector('.code-header .copy-btn')
: codeBlock.querySelector('.copy-icon-btn')
if (codeBlock.dataset.copyable !== 'true' || !copyBtn)
return
copyBtn.addEventListener('click', () => {
const iswWrap = codeBlock.classList.contains('line-wrapping')
const highlightLines = codeBlock.querySelectorAll('.hl')
iswWrap && codeBlock.classList.toggle('line-wrapping')
highlightLines.forEach(($hl) => {
$hl.classList.toggle('hl')
})
copyText(codePreEl.textContent!.trim()).then(() => {
animateCSS(codePreEl, 'animate__flash')
iswWrap && codeBlock.classList.toggle('line-wrapping')
highlightLines.forEach(($hl) => {
$hl.classList.toggle('hl')
})
flashCopiedTooltip(copyBtn)
}, () => {
console.error('Clipboard write failed!', 'Your browser does not support clipboard API!')
})
}, false)
}
/**
* Attach toggle behaviour to the code expand/collapse button.
* @param codeBlock - The `.code-block` container element.
*/
initCodeExpandBtn(codeBlock: HTMLElement) {
codeBlock.querySelector('.code-expand-btn')?.addEventListener('click', () => {
codeBlock.classList.toggle('is-expanded')
}, false)
}
/**
* Attach download behaviour to a code block's download button.
* @param codeBlock - The `.code-block` container element.
* @param codePreEl - The `` element containing the code text.
*/
initDownloadCode(codeBlock: HTMLElement, codePreEl: HTMLElement) {
const downloadBtn = codeBlock.querySelector('.code-header .download-btn')
if (!downloadBtn)
return
downloadBtn.addEventListener('click', () => {
const $codeHeader = codeBlock.querySelector('.code-header')
const name = codeBlock.dataset.name?.trim()
const language = Array.from($codeHeader?.classList || []).find(className => className.startsWith('language-'))?.replace('language-', '')
const ext = language && language !== 'fallback' ? language : 'txt'
const fallbackName = name
? (name.includes('.') ? name : `${name}.${ext}`)
: `code.${ext}`
const fileName = codeBlock.getAttribute('filename')?.trim()
downloadAsFile(codePreEl.textContent!, fileName || fallbackName)
downloadBtn.toggleAttribute('data-downloaded', true)
downloadBtn.classList.toggle('fa-spin', true)
setTimeout(() => {
downloadBtn.toggleAttribute('data-downloaded', false)
downloadBtn.classList.toggle('fa-spin', false)
}, 300)
}, false)
}
/**
* Get the fullscreen target element (parent `.code-tabs` or the block itself).
* @param codeBlock - The `.code-block` element.
* @returns The element to apply fullscreen to.
*/
#getCodeFullscreenTarget(codeBlock: HTMLElement): HTMLElement {
return (codeBlock.closest('.code-tabs') as HTMLElement) || codeBlock
}
/**
* Toggle fullscreen state and update button tooltips.
* @param codeBlock - The `.code-block` element.
* @param show - `true` to enter fullscreen, `false` to exit.
*/
#setCodeFullscreenState(codeBlock: HTMLElement, show: boolean) {
const target = this.#getCodeFullscreenTarget(codeBlock)
const expandBtn = codeBlock.querySelector('.code-expand-btn')
if (show && expandBtn) {
codeBlock.dataset.fullscreenExpanded = codeBlock.classList.contains('is-expanded') ? 'true' : 'false'
codeBlock.classList.add('is-expanded')
}
if (!show && target.classList.contains('is-fullscreen')) {
target.classList.add('instant-height')
window.requestAnimationFrame(() => target.classList.remove('instant-height'))
if (expandBtn && codeBlock.dataset.fullscreenExpanded === 'false') {
codeBlock.classList.remove('is-expanded')
}
delete codeBlock.dataset.fullscreenExpanded
}
// update button tooltip
target.classList.toggle('is-fullscreen', show)
const btn = target.querySelector('.tabs-actions .fullscreen-btn')
|| codeBlock.querySelector('.code-header .fullscreen-btn')
if (!btn)
return
const exitTitle = btn.dataset.exitTitle || btn.getAttribute('data-exit-title') || btn.title
const originalTitle = btn.dataset.ctOriginalTitle || btn.dataset.ctTitle || btn.title
btn.dataset.ctOriginalTitle = originalTitle
btn.dataset.ctTitle = show ? exitTitle : originalTitle
const instance = CellTooltip.getOrCreateInstance(btn)
instance.hide()
}
/** Exit fullscreen on the currently active code block. */
closeCodeFullscreen() {
const $activeTabs = document.querySelector('.code-tabs.is-fullscreen')
if ($activeTabs) {
const $activeBlock = $activeTabs.querySelector('.code-block.active') || $activeTabs.querySelector('.code-block')
if ($activeBlock)
this.#setCodeFullscreenState($activeBlock, false)
return
}
const $activeBlock = document.querySelector('.code-block.highlight.is-fullscreen')
if ($activeBlock)
this.#setCodeFullscreenState($activeBlock, false)
}
/**
* Attach fullscreen toggle and Escape-key handler to a code block.
* @param codeBlock - The `.code-block` container element.
*/
initFullscreenCode(codeBlock: HTMLElement) {
const fullscreenBtn = codeBlock.querySelector('.code-header .fullscreen-btn')
if (!fullscreenBtn)
return
fullscreenBtn.addEventListener('click', () => {
const target = this.#getCodeFullscreenTarget(codeBlock)
const show = !target.classList.contains('is-fullscreen')
if (show) {
this.closeCodeFullscreen()
codeBlock.classList.remove('is-collapsed')
}
this.#setCodeFullscreenState(codeBlock, show)
}, false)
if (!this.#fullscreenAbort) {
this.#fullscreenAbort = new AbortController()
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape')
this.closeCodeFullscreen()
}, { signal: this.#fullscreenAbort.signal })
}
}
/** Initialize all un-initialized code blocks on the page. */
initCodeWrapper() {
const $codeBlocks = document.querySelectorAll('.code-block.highlight:not([data-init])')
$codeBlocks.forEach(($codeBlock) => {
const $preElements = $codeBlock.querySelectorAll('pre.chroma')
if (!$preElements.length)
return
const $codePreEl = $preElements[$preElements.length - 1]
$codeBlock.dataset.init = 'true'
this.initCopyCode($codeBlock, $codePreEl)
this.initCodeExpandBtn($codeBlock)
// classic mode code block interactions
if ($codeBlock.dataset.mode === 'classic') {
const $codeHeader = $codeBlock.querySelector('.code-header')
if (!$codeHeader)
return
this.initDownloadCode($codeBlock, $codePreEl)
this.initFullscreenCode($codeBlock)
// code title
$codeHeader.querySelector('.code-title')!.addEventListener('click', () => {
if ($codeBlock.classList.contains('is-fullscreen'))
return
$codeBlock.classList.toggle('is-collapsed')
}, false)
// ellipses icon
$codeHeader.querySelector('.ellipses-btn')!.addEventListener('click', () => {
$codeBlock.classList.remove('is-collapsed')
}, false)
// line numbers toggle button
$codeHeader.querySelector('.line-nos-btn')?.addEventListener('click', () => {
$codeBlock.classList.toggle('line-nos-hidden')
}, false)
// line wrapping toggle button
$codeHeader.querySelector('.line-wrap-btn')?.addEventListener('click', () => {
if ($codeBlock.querySelector('[contenteditable="true"]'))
return
$codeBlock.classList.toggle('line-wrapping')
}, false)
// edit button toggle button
if ($codeBlock.dataset.editable === 'true') {
$codeHeader.querySelector('.edit-btn')?.addEventListener('click', () => {
const isEditable = $codePreEl.getAttribute('contenteditable') === 'true'
if (isEditable) {
$codePreEl.setAttribute('contenteditable', 'false')
$codePreEl.blur()
}
else {
$codeBlock.querySelectorAll('.hl').forEach(($hl: Element) => {
$hl.classList.remove('hl')
})
$codeBlock.classList.add('is-expanded')
$codeBlock.classList.remove('line-wrapping')
$codePreEl.setAttribute('contenteditable', 'true')
$codePreEl.focus()
}
}, false)
}
}
})
}
/** Group consecutive code blocks into tabbed containers with language sync. */
initCodeTabs() {
const $codeBlocks = document.querySelectorAll('.code-block[group]:not([data-tab-init])')
const processed = new Set()
const normalizeTabTitle = (title = '') => title.toLowerCase()
$codeBlocks.forEach(($block) => {
if (processed.has($block))
return
const groupName = $block.getAttribute('group')!
const $tabs: HTMLElement[] = []
let $curr: HTMLElement | null = $block
// collect consecutive blocks with same group
while ($curr && $curr.classList?.contains('code-block') && $curr.getAttribute('group') === groupName) {
$tabs.push($curr)
processed.add($curr)
$curr = $curr.nextElementSibling as HTMLElement
}
if ($tabs.length < 2)
return
// create DOM structure
const $container = document.createElement('div')
$container.className = 'code-tabs'
const $header = document.createElement('div')
$header.className = 'tabs-header'
const $items = document.createElement('div')
$items.className = 'tabs-items'
const $actions = document.createElement('div')
$actions.className = 'tabs-actions'
$header.appendChild($items)
$header.appendChild($actions)
const $content = document.createElement('div')
$content.className = 'tabs-content'
// insert container before the first block
const $firstBlock = $tabs[0]
$firstBlock.parentNode!.insertBefore($container, $firstBlock)
const activeTabIndex = $tabs.findIndex(tab => tab.classList.contains('active'))
const langPref = window.localStorage.getItem('config_lang_perf')
const hasCodeToggle = $tabs.some(tab => tab.dataset.codeToggle === 'true')
const langPrefIndex = (langPref && hasCodeToggle) ? $tabs.findIndex(tab => tab.dataset.tabTitle!.toLowerCase() === langPref) : -1
const resolvedIndex = langPrefIndex !== -1 ? langPrefIndex : activeTabIndex
const beforeTabs = $tabs[0]?.getAttribute('before_tabs')
if (beforeTabs) {
const $before = document.createElement('span')
$before.className = 'before-tabs'
$before.textContent = beforeTabs
$items.appendChild($before)
}
const tabButtons: HTMLElement[] = []
const toggleLangToIndex = new Map()
const switchToTab = (index: number) => {
const $nextTab = $tabs[index]
const $nextBtn = tabButtons[index]
if (!$nextTab || !$nextBtn)
return
// 1. restore buttons to the currently active tab
const $activeTab = $tabs.find(t => t.classList.contains('active'))
if ($activeTab) {
const $activeHeader = $activeTab.querySelector('.code-header')
if ($activeHeader) {
Array.from($actions.children).forEach(btn => $activeHeader.appendChild(btn))
}
}
// 2. switch active tab UI
tabButtons.forEach(b => b.classList.remove('active'))
$nextBtn.classList.add('active')
// 3. switch content
$tabs.forEach(b => b.classList.remove('active'))
$nextTab.classList.add('active')
// 4. sync shadow mode data attribute
const shadowMode = $nextTab?.dataset.shadow
if (shadowMode) {
$container.dataset.shadow = shadowMode
}
else {
delete $container.dataset.shadow
}
// 5. move new buttons to actions
const $codeHeader = $nextTab.querySelector('.code-header')
if ($codeHeader) {
$codeHeader.querySelectorAll('.action-btn').forEach(btn => $actions.appendChild(btn))
}
}
eventBus.on('fixit:code-tab-sync', ({ detail }) => {
if (!detail.lang || detail.source === $container)
return
const index = toggleLangToIndex.get(detail.lang)
index !== undefined && switchToTab(index)
})
$tabs.forEach(($tab, index) => {
const title = $tab.dataset.tabTitle || 'Code'
const defaultActiveTab = resolvedIndex === -1 && index === 0
// tab button
const $btn = document.createElement('span')
$btn.className = 'tab-item'
if (defaultActiveTab)
$btn.classList.add('active')
$btn.textContent = title
$btn.dataset.index = String(index)
$btn.title = title
tabButtons.push($btn)
const normalizedTitle = normalizeTabTitle(title)
if (!toggleLangToIndex.has(normalizedTitle)) {
toggleLangToIndex.set(normalizedTitle, index)
}
$btn.addEventListener('click', () => {
if ($tab.dataset.codeToggle === 'true') {
window.localStorage.setItem('config_lang_perf', normalizedTitle)
eventBus.emit('fixit:code-tab-sync', { lang: normalizedTitle, source: $container })
}
switchToTab(index)
})
$items.appendChild($btn)
// move block to content
$tab.classList.toggle('active', resolvedIndex === index || defaultActiveTab)
$tab.classList.remove('is-collapsed')
$tab.classList.remove('hidden')
$tab.dataset.tabInit = 'true'
$content.appendChild($tab)
})
$container.appendChild($header)
$container.appendChild($content)
// initialize actions for the active tab
if (resolvedIndex !== -1) {
switchToTab(resolvedIndex)
}
else {
switchToTab(0)
}
})
}
/** Attach copy behaviour to diagram container copy buttons. */
initDiagramCopyBtn() {
const stagingDOM = getStagingDOM()
document.querySelectorAll('.diagram-container > .copy-icon-btn').forEach(($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 { /* ignore */ }
copyText(code).then(() => {
flashCopiedTooltip($btn as HTMLElement)
}, () => {
console.error('Clipboard write failed!', 'Your browser does not support clipboard API!')
})
}, false)
})
stagingDOM.destroy()
}
}