mirror of
https://github.com/hugo-fixit/FixIt.git
synced 2026-08-24 07:18:57 +00:00
8af8e806d7
* refactor(assets): integrate UnoCSS for utility classes - Add UnoCSS with presetWind3 as pre-built CSS (assets/css/unocss.css) - Add uno.config.ts with theme breakpoints, colors, safelist, and blocklist - Replace SCSS utility classes with UnoCSS atomic classes: - d-none → hidden - d-none-desktop → sm:hidden - d-none-mobile → max-sm:hidden - order-* (safelist) - variant-numeric → tabular-nums lining-nums - Rename .blur to .is-blur to avoid UnoCSS conflict - Add @unocss-skip comments for SVG path elements to prevent false positives - Remove redundant _utilities.scss ($orders map) and simplify _common.scss - Update documentation (CLAUDE.md, CONTRIBUTING.md, copilot-instructions.md) * refactor(assets): replace 404/offline SCSS with UnoCSS atomic classes - Replace _404.scss styles with atomic classes on template elements - Replace _offline.scss styles with atomic classes on template elements - Keep selectors (id/class) as user customization hooks - Delete assets/scss/pages/_404.scss and _offline.scss * refactor(assets): simplify SCSS with UnoCSS atomic classes - Replace _bilibili.scss with atomic classes (relative, aspect-video, inset-0) - Replace _version.scss with atomic classes (whitespace-nowrap, align-text-bottom) - Replace _noscript-warning.scss with atomic classes (bg-danger, fixed, z-200) - Add semantic z-index shortcuts (z-hide, z-auto, z-base, z-loading, z-sticky, z-fixed) - Add h1-h6 to UnoCSS blocklist to prevent false positives - Use Source path for UnoCSS CSS loading to enable Hugo minify * chore(assets): minor SCSS and template cleanup * fix(assets): add secondary color to UnoCSS theme config text-secondary was broken because the secondary color mapping was missing. * feat(assets): add UnoCSS presetIcons support and z-index shortcuts - Add presetIcons preset with empty collections (ready for custom icon sets) - Add @iconify/json and @iconify/utils devDependencies - Add semantic z-index shortcuts (z-hide, z-auto, z-base, z-loading, z-sticky, z-fixed) - Add secondary color to UnoCSS theme config * refactor(assets): replace SVG icons with UnoCSS presetIcons - Add Lucide and Octicons icon collections to presetIcons config - Replace arrow-up/down and enter-key SVGs with Lucide icons - Replace alert icons (info, light-bulb, report, alert, stop) with Octicons - Delete replaced SVG files from assets/images/icons/ - Keep custom SVGs (csdn, plume, rootme) * refactor(layouts): redesign 404 and offline pages with UnoCSS - Replace translate-y-[30vh] with self-stretch flex centering below header - Remove arbitrary values (text-[3.6rem], text-[1.8rem], etc.) - Restore @page print styles in _common.scss - Simplify offline page layout (icon + title + text) - Fix hardcoded #57606a → text-secondary
419 lines
16 KiB
TypeScript
419 lines
16 KiB
TypeScript
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 `<pre>` element containing the code text.
|
|
*/
|
|
initCopyCode(codeBlock: HTMLElement, codePreEl: HTMLElement) {
|
|
const copyBtn = codeBlock.dataset.mode === 'classic'
|
|
? codeBlock.querySelector<HTMLElement>('.code-header .copy-btn')
|
|
: codeBlock.querySelector<HTMLElement>('.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 `<pre>` element containing the code text.
|
|
*/
|
|
initDownloadCode(codeBlock: HTMLElement, codePreEl: HTMLElement) {
|
|
const downloadBtn = codeBlock.querySelector<HTMLElement>('.code-header .download-btn')
|
|
if (!downloadBtn)
|
|
return
|
|
downloadBtn.addEventListener('click', () => {
|
|
const $codeHeader = codeBlock.querySelector<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.tabs-actions .fullscreen-btn')
|
|
|| codeBlock.querySelector<HTMLElement>('.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<HTMLElement>('.code-tabs.is-fullscreen')
|
|
if ($activeTabs) {
|
|
const $activeBlock = $activeTabs.querySelector<HTMLElement>('.code-block.active') || $activeTabs.querySelector<HTMLElement>('.code-block')
|
|
if ($activeBlock)
|
|
this.#setCodeFullscreenState($activeBlock, false)
|
|
return
|
|
}
|
|
const $activeBlock = document.querySelector<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.code-block.highlight:not([data-init])')
|
|
$codeBlocks.forEach(($codeBlock) => {
|
|
const $preElements = $codeBlock.querySelectorAll<HTMLPreElement>('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<HTMLElement>('.code-header')
|
|
if (!$codeHeader)
|
|
return
|
|
this.initDownloadCode($codeBlock, $codePreEl)
|
|
this.initFullscreenCode($codeBlock)
|
|
// code title
|
|
$codeHeader.querySelector<HTMLElement>('.code-title')!.addEventListener('click', () => {
|
|
if ($codeBlock.classList.contains('is-fullscreen'))
|
|
return
|
|
$codeBlock.classList.toggle('is-collapsed')
|
|
}, false)
|
|
// ellipses icon
|
|
$codeHeader.querySelector<HTMLElement>('.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<HTMLElement>('.code-block[group]:not([data-tab-init])')
|
|
const processed = new Set<HTMLElement>()
|
|
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<string, number>()
|
|
|
|
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<HTMLElement>('.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<HTMLElement>('.code-header')
|
|
if ($codeHeader) {
|
|
$codeHeader.querySelectorAll<HTMLElement>('.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<HTMLElement>('.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()
|
|
}
|
|
}
|