mirror of
https://github.com/hugo-fixit/FixIt.git
synced 2026-08-24 15:28:57 +00:00
908 lines
28 KiB
TypeScript
908 lines
28 KiB
TypeScript
/**
|
|
* Mermaid runtime integration for FixIt.
|
|
*
|
|
* This module is responsible for rendering and maintaining Mermaid diagrams after
|
|
* bootstrapMermaid() loads Mermaid and optional extensions dynamically.
|
|
*
|
|
* Main responsibilities:
|
|
* - Lazy render Mermaid blocks via IntersectionObserver to reduce initial cost.
|
|
* - Render only the currently active diagram layer (light/dark), plus idle-time
|
|
* neutral rendering for fallback/print scenarios.
|
|
* - Keep Mermaid global initialize() calls serialized to avoid concurrent
|
|
* initialize/render cross-talk.
|
|
* - 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 and `fixit:content-decrypted`)
|
|
* and re-observe/re-render affected containers when context changes.
|
|
*
|
|
* Public entrypoints:
|
|
* - initMermaidRuntime(runtime): register loaded runtime dependencies and bind listeners.
|
|
* - bootstrapMermaid(options): dynamic-import bootstrap entry used by template script.
|
|
*/
|
|
import type {
|
|
MermaidConfig,
|
|
MermaidRuntime,
|
|
MermaidRuntimeModule,
|
|
PanzoomInstance,
|
|
PanzoomTransform,
|
|
TabContainerChangedEvent,
|
|
} from '../types'
|
|
import { eventBus } from '../core/event-bus'
|
|
import { isDarkMode } from '../utils'
|
|
|
|
interface MermaidPanzoomGroup {
|
|
container: Element
|
|
transform: PanzoomTransform | null
|
|
svgs: Set<SVGElement>
|
|
lastSvg: SVGElement | null
|
|
}
|
|
|
|
interface RenderOptions {
|
|
theme: string
|
|
darkMode: boolean
|
|
}
|
|
|
|
let mermaid: MermaidRuntimeModule | null = null
|
|
let config: MermaidConfig = {}
|
|
let hasBoundGlobalEvents = false
|
|
let mermaidContainerObserver: IntersectionObserver | null = null
|
|
let mermaidRenderLock: Promise<void> = Promise.resolve()
|
|
let mermaidConfigKey = ''
|
|
let mermaidIdSeq = 0
|
|
|
|
let panzoomInstances: WeakMap<SVGElement, PanzoomInstance> | null = null
|
|
let panzoomWheelHosts: WeakSet<Element> | null = null
|
|
let mermaidPanzoomGroups: WeakMap<Element, MermaidPanzoomGroup> | null = null
|
|
|
|
/**
|
|
* Gets (and initializes) the pan/zoom shared state for a Mermaid diagram container.
|
|
* The group keeps transforms in sync across `.mermaid` and `.mermaid-dark` layers.
|
|
* @param svg Mermaid SVG element inside a diagram container.
|
|
* @returns Shared pan/zoom group state, or null when element is out of supported context.
|
|
*/
|
|
function getMermaidPanzoomGroup(svg: SVGElement): MermaidPanzoomGroup | null {
|
|
if (!svg.closest('.diagram-view'))
|
|
return null
|
|
const container = svg.closest('.diagram-container')
|
|
if (!container)
|
|
return null
|
|
|
|
if (!mermaidPanzoomGroups)
|
|
mermaidPanzoomGroups = new WeakMap()
|
|
|
|
let group = mermaidPanzoomGroups.get(container)
|
|
if (!group) {
|
|
group = { container, transform: null, svgs: new Set(), lastSvg: null }
|
|
mermaidPanzoomGroups.set(container, group)
|
|
}
|
|
|
|
const svgs = container.querySelectorAll<SVGElement>('.mermaid svg, .mermaid-dark svg')
|
|
svgs.forEach((item) => {
|
|
group?.svgs.add(item)
|
|
})
|
|
|
|
return group
|
|
}
|
|
|
|
/**
|
|
* Compares pan/zoom transforms with a small tolerance.
|
|
* Used to avoid redundant sync work during repeated theme switches / re-renders.
|
|
* @param a Source transform.
|
|
* @param b Target transform.
|
|
* @returns Whether two transforms are effectively the same.
|
|
*/
|
|
function isSamePanzoomTransform(a: PanzoomTransform | null, b: PanzoomTransform | null): boolean {
|
|
if (!a || !b)
|
|
return false
|
|
|
|
const eps = 1e-3
|
|
return (
|
|
Math.abs(a.x - b.x) <= eps
|
|
&& Math.abs(a.y - b.y) <= eps
|
|
&& Math.abs(a.scale - b.scale) <= eps
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Applies a pan/zoom transform to an instance.
|
|
* The pan step is deferred to the next macrotask so zoom can settle first.
|
|
* @param instance Panzoom instance.
|
|
* @param transform Target transform.
|
|
*/
|
|
function applyPanzoomTransform(instance: PanzoomInstance | null | undefined, transform: PanzoomTransform | null): void {
|
|
if (!instance || !transform)
|
|
return
|
|
|
|
const currentTransform: PanzoomTransform = {
|
|
scale: instance.getScale(),
|
|
...instance.getPan(),
|
|
}
|
|
if (isSamePanzoomTransform(currentTransform, transform))
|
|
return
|
|
|
|
instance.zoom(transform.scale, { animate: false, force: true })
|
|
setTimeout(() => instance.pan(transform.x, transform.y, { animate: false, force: true }))
|
|
}
|
|
|
|
/**
|
|
* Schedule low-priority work without blocking critical rendering.
|
|
* @param task Deferred task callback.
|
|
* @param timeout Timeout in milliseconds when browser supports requestIdleCallback.
|
|
*/
|
|
function runWhenIdle(task: () => void, timeout = 0): void {
|
|
if (typeof window.requestIdleCallback === 'function') {
|
|
window.requestIdleCallback(task, { timeout })
|
|
return
|
|
}
|
|
window.setTimeout(task, 80)
|
|
}
|
|
|
|
/**
|
|
* Checks whether a node is currently visible and should be rendered first.
|
|
* @param node Candidate Mermaid host element.
|
|
* @returns Whether node is currently visible and renderable.
|
|
*/
|
|
function isNodeVisuallyActive(node: Element): boolean {
|
|
if (!isRenderContextActive(node))
|
|
return false
|
|
|
|
const nodeStyle = getComputedStyle(node)
|
|
if (nodeStyle.display === 'none' || nodeStyle.visibility === 'hidden')
|
|
return false
|
|
|
|
const rect = node.getBoundingClientRect()
|
|
return rect.width > 0 && rect.height > 0
|
|
}
|
|
|
|
/**
|
|
* Checks whether an element is inside an active renderable context.
|
|
* This excludes hidden tab panels / diagram layers and common hidden containers.
|
|
* @param el Candidate element.
|
|
* @returns Whether element is active in current render context.
|
|
*/
|
|
function isRenderContextActive(el: Element): boolean {
|
|
if (!el.isConnected)
|
|
return false
|
|
if (el.closest('[hidden], .hidden, [aria-hidden="true"]'))
|
|
return false
|
|
|
|
const diagramView = el.closest('.diagram-view')
|
|
if (diagramView?.closest('.diagram-tabs') && !diagramView.classList.contains('active'))
|
|
return false
|
|
|
|
const panel = el.closest('.tab-panel')
|
|
if (panel) {
|
|
// tab-container uses slot-based hiding; check actual layout visibility
|
|
const panelRect = panel.getBoundingClientRect()
|
|
if (panelRect.width === 0 && panelRect.height === 0)
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Gets Mermaid theme name for the current effective color scheme.
|
|
* @returns Current Mermaid theme.
|
|
*/
|
|
function getTheme(): string {
|
|
const themes = config.themes ?? ['default', 'dark']
|
|
const lightTheme = themes[0] ?? 'default'
|
|
const darkTheme = themes[1] ?? lightTheme
|
|
return isDarkMode() ? darkTheme : lightTheme
|
|
}
|
|
|
|
/**
|
|
* Mermaid uses global runtime config; initialize() affects subsequent render().
|
|
* This lock serializes all render work to avoid concurrent initialize/render cross-talk.
|
|
* @param task Async render task.
|
|
* @returns Task result.
|
|
*/
|
|
function withMermaidLock<T>(task: () => Promise<T>): Promise<T> {
|
|
const run = mermaidRenderLock.then(task, task)
|
|
// Keep the chain alive even after errors so subsequent renders are not blocked.
|
|
mermaidRenderLock = run.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
)
|
|
return run
|
|
}
|
|
|
|
/**
|
|
* Initializes Mermaid only when (theme, darkMode) changes.
|
|
* @param theme Mermaid theme name.
|
|
* @param darkMode Whether dark mode is currently active.
|
|
*/
|
|
function ensureMermaidInitialized(theme: string, darkMode: boolean): void {
|
|
if (!mermaid)
|
|
return
|
|
|
|
const key = `${String(theme)}|${darkMode ? '1' : '0'}`
|
|
if (key === mermaidConfigKey)
|
|
return
|
|
|
|
// Mermaid initialize() mutates global runtime behavior for following render() calls.
|
|
mermaidConfigKey = key
|
|
mermaid.initialize({
|
|
startOnLoad: false,
|
|
darkMode,
|
|
theme,
|
|
securityLevel: config.securityLevel,
|
|
look: config.look,
|
|
layout: config.layout,
|
|
fontFamily: config.fontFamily,
|
|
altFontFamily: config.fontFamily,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Renders a single Mermaid host element (`.mermaid`, `.mermaid-dark`, `.mermaid-neutral`).
|
|
* Source uses textContent to avoid HTML entities breaking Mermaid parsing.
|
|
* @param el Mermaid host element.
|
|
* @param options Render options with theme and dark mode.
|
|
*/
|
|
async function renderMermaidElement(el: Element | null, options: RenderOptions): Promise<void> {
|
|
if (!el || !mermaid || el.hasAttribute('data-processed') || el.hasAttribute('data-processing'))
|
|
return
|
|
if (!isRenderContextActive(el))
|
|
return
|
|
|
|
const source = el.textContent?.trim() ?? ''
|
|
if (!source) {
|
|
el.setAttribute('data-processed', '')
|
|
return
|
|
}
|
|
|
|
await withMermaidLock(async () => {
|
|
if (!el || !mermaid || el.hasAttribute('data-processed') || !isRenderContextActive(el))
|
|
return
|
|
|
|
// Mark processing to prevent duplicate renders from intersect/theme/tab events.
|
|
if (!el.hasAttribute('data-processing'))
|
|
el.setAttribute('data-processing', '')
|
|
|
|
ensureMermaidInitialized(options.theme, options.darkMode)
|
|
|
|
const id = `fixit-mermaid-${++mermaidIdSeq}`
|
|
try {
|
|
const result = await mermaid.render(id, source, el)
|
|
const svg = result?.svg ?? ''
|
|
svg && (el.innerHTML = svg)
|
|
if (typeof result?.bindFunctions === 'function') {
|
|
try {
|
|
result.bindFunctions(el)
|
|
}
|
|
catch {
|
|
// Ignore bind errors from third-party diagrams.
|
|
}
|
|
}
|
|
|
|
const svgEl = el.querySelector<SVGElement>('svg')
|
|
if (svgEl && !svgEl.id)
|
|
svgEl.id = id
|
|
|
|
el.removeAttribute('data-processing')
|
|
el.setAttribute('data-processed', '')
|
|
|
|
const svgId = svgEl?.id ?? id
|
|
if (svgId) {
|
|
const renderedSvg = document.getElementById(svgId)
|
|
if (renderedSvg instanceof SVGElement)
|
|
bindMermaidPanzoom(renderedSvg)
|
|
}
|
|
}
|
|
catch (error) {
|
|
const errDiv = el.querySelector<HTMLElement>(`#d${id}`)
|
|
if (errDiv) {
|
|
el.innerHTML = errDiv.innerHTML
|
|
}
|
|
el.removeAttribute('data-processing')
|
|
el.setAttribute('data-processed', '')
|
|
console.warn('FixIt Mermaid render failed:', error)
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Collect all diagram containers that contain Mermaid elements.
|
|
* @param root Search root.
|
|
* @returns Diagram containers that contain Mermaid blocks.
|
|
*/
|
|
function collectMermaidContainers(root: ParentNode = document): HTMLElement[] {
|
|
return Array.from(root.querySelectorAll<HTMLElement>('.diagram-container'))
|
|
.filter(container => container.querySelector('.mermaid, .mermaid-dark, .mermaid-neutral'))
|
|
}
|
|
|
|
/**
|
|
* Core rule: render only when the active layer is visible and not rendered yet.
|
|
* Active layer is `.mermaid` in light mode or `.mermaid-dark` in dark mode.
|
|
* @param container Diagram container.
|
|
*/
|
|
async function renderActiveLayerInContainer(container: Element | null): Promise<void> {
|
|
if (!container)
|
|
return
|
|
|
|
const darkMode = isDarkMode()
|
|
const el = container.querySelector(darkMode ? '.mermaid-dark' : '.mermaid')
|
|
if (!el)
|
|
return
|
|
if (el.hasAttribute('data-processed') || el.querySelector('svg'))
|
|
return
|
|
if (!isNodeVisuallyActive(el))
|
|
return
|
|
|
|
await renderMermaidElement(el, { theme: getTheme(), darkMode })
|
|
}
|
|
|
|
/**
|
|
* Neutral theme is not part of live light/dark switching UI.
|
|
* Render it in idle time for print / fallback scenarios.
|
|
*/
|
|
function scheduleNeutralRender(): void {
|
|
if (window.matchMedia('only screen and (max-width: 960px)').matches)
|
|
return
|
|
|
|
runWhenIdle(() => {
|
|
const neutralNodes = Array.from(document.querySelectorAll<Element>('.mermaid-neutral'))
|
|
.filter(el => !el.hasAttribute('data-processed'))
|
|
.filter(isRenderContextActive)
|
|
|
|
neutralNodes.forEach((el) => {
|
|
void renderMermaidElement(el, { theme: 'neutral', darkMode: false })
|
|
})
|
|
})
|
|
}
|
|
|
|
/** Lazy rendering via IntersectionObserver. */
|
|
function bindMermaidIntersectionObserver(): void {
|
|
if (mermaidContainerObserver)
|
|
return
|
|
|
|
mermaidContainerObserver = new IntersectionObserver((entries) => {
|
|
entries.forEach((entry) => {
|
|
if (!entry.isIntersecting)
|
|
return
|
|
const container = entry.target
|
|
if (!container || !isRenderContextActive(container))
|
|
return
|
|
void renderActiveLayerInContainer(container)
|
|
})
|
|
}, {
|
|
root: null,
|
|
// 200px preloading margin lets diagrams render slightly before entering viewport.
|
|
rootMargin: '200px 0px',
|
|
threshold: 0.01,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Registers Mermaid containers under root into the observer.
|
|
* @param root Search root.
|
|
* @param refresh Whether to re-observe matched containers.
|
|
*/
|
|
function observeMermaidContainers(root: ParentNode = document, refresh = false): void {
|
|
bindMermaidIntersectionObserver()
|
|
if (!mermaidContainerObserver)
|
|
return
|
|
|
|
const containers = collectMermaidContainers(root)
|
|
containers.forEach((container) => {
|
|
if (container.hasAttribute('data-mermaid-observed'))
|
|
return
|
|
container.setAttribute('data-mermaid-observed', '')
|
|
mermaidContainerObserver?.observe(container)
|
|
})
|
|
|
|
if (!refresh)
|
|
return
|
|
|
|
// Force observer callback refresh after context switches (tab/theme/decrypt events).
|
|
containers.forEach((container) => {
|
|
mermaidContainerObserver?.unobserve(container)
|
|
mermaidContainerObserver?.observe(container)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Gets the currently visible SVG for a diagram block with multiple Mermaid layers.
|
|
* @param root Diagram block root element.
|
|
* @returns Visible Mermaid SVG, or null.
|
|
*/
|
|
function getActiveMermaidSvg(root: Element | null): SVGElement | null {
|
|
const nodes = root?.querySelectorAll('.mermaid, .mermaid-dark, .mermaid-neutral')
|
|
if (!nodes?.length)
|
|
return null
|
|
|
|
const visible = Array.from(nodes).find((el) => {
|
|
const style = getComputedStyle(el)
|
|
if (style.display === 'none' || style.visibility === 'hidden')
|
|
return false
|
|
const rect = el.getBoundingClientRect()
|
|
return rect.width > 0 && rect.height > 0
|
|
})
|
|
|
|
return visible?.querySelector<SVGElement>('svg') ?? null
|
|
}
|
|
|
|
/**
|
|
* Enables pan/zoom for a Mermaid SVG inside `.diagram-view`.
|
|
* @param svg Mermaid SVG element.
|
|
*/
|
|
function bindMermaidPanzoom(svg: SVGElement): void {
|
|
if (!svg.closest('.diagram-view'))
|
|
return
|
|
if (!svg.closest('.mermaid, .mermaid-dark'))
|
|
return
|
|
|
|
if (!panzoomInstances)
|
|
panzoomInstances = new WeakMap()
|
|
if (!panzoomWheelHosts)
|
|
panzoomWheelHosts = new WeakSet()
|
|
|
|
if (panzoomInstances.has(svg))
|
|
return
|
|
if (typeof window.Panzoom !== 'function')
|
|
return
|
|
|
|
const group = getMermaidPanzoomGroup(svg)
|
|
const panzoom = window.Panzoom(svg, {
|
|
maxScale: 6,
|
|
minScale: 0.2,
|
|
step: 0.1,
|
|
})
|
|
panzoomInstances.set(svg, panzoom)
|
|
|
|
if (group?.transform)
|
|
applyPanzoomTransform(panzoom, group.transform)
|
|
|
|
const host = svg.closest<HTMLElement>('.mermaid, .mermaid-dark')
|
|
if (!host)
|
|
return
|
|
if (panzoomWheelHosts.has(host))
|
|
return
|
|
|
|
panzoomWheelHosts.add(host)
|
|
|
|
host.addEventListener('pointerdown', () => {
|
|
const currentSvg = host.querySelector<SVGElement>('svg')
|
|
if (group && currentSvg)
|
|
group.lastSvg = currentSvg
|
|
}, false)
|
|
|
|
host.addEventListener('wheel', (event: WheelEvent) => {
|
|
if (!event.ctrlKey)
|
|
return
|
|
|
|
const currentSvg = host.querySelector<SVGElement>('svg')
|
|
const instance = currentSvg ? panzoomInstances?.get(currentSvg) : null
|
|
if (!instance)
|
|
return
|
|
|
|
if (group && currentSvg)
|
|
group.lastSvg = currentSvg
|
|
|
|
event.preventDefault()
|
|
instance.zoomWithWheel(event)
|
|
}, { passive: false })
|
|
}
|
|
|
|
/**
|
|
* Initializes diagram tabs controls:
|
|
* - Diagram/Code tab switching
|
|
* - Zoom/reset/download buttons wiring
|
|
* @param tabs Diagram tabs root element.
|
|
*/
|
|
async function initSingleDiagramTabs(tabs: HTMLElement): Promise<void> {
|
|
const actions = tabs.querySelector<HTMLElement>('.tabs-actions')
|
|
const diagramTabBtn = tabs.querySelector<HTMLElement>('.tab-item[data-tab="diagram"]')
|
|
const codeTabBtn = tabs.querySelector<HTMLElement>('.tab-item[data-tab="code"]')
|
|
const diagramBlock = tabs.querySelector<HTMLElement>('.diagram-view')
|
|
const codeBlock = Array.from(tabs.querySelectorAll<HTMLElement>('.tabs-content > .code-block'))
|
|
.find(block => !block.classList.contains('diagram-view'))
|
|
|
|
if (!actions || !diagramTabBtn || !codeTabBtn || !diagramBlock || !codeBlock)
|
|
return
|
|
|
|
const zoomInBtn = tabs.querySelector<HTMLElement>('.diagram-zoom-in-btn')
|
|
const zoomOutBtn = tabs.querySelector<HTMLElement>('.diagram-zoom-out-btn')
|
|
const resetBtn = tabs.querySelector<HTMLElement>('.diagram-reset-btn')
|
|
const downloadBtn = tabs.querySelector<HTMLElement>('.diagram-download-btn')
|
|
tabs.dataset.diagramInit = 'true'
|
|
|
|
const diagramActionButtons = [zoomOutBtn, zoomInBtn, resetBtn, downloadBtn].filter(Boolean) as HTMLElement[]
|
|
const movedActionRestore = new WeakMap<HTMLElement, { parent: HTMLElement, next: ChildNode | null }>()
|
|
let codeActionCache: HTMLElement[] = []
|
|
|
|
const pinnedFullscreenBtn = codeBlock.querySelector<HTMLElement>('.code-header .fullscreen-btn')
|
|
if (pinnedFullscreenBtn && pinnedFullscreenBtn.parentElement !== actions) {
|
|
actions.appendChild(pinnedFullscreenBtn)
|
|
}
|
|
|
|
const collectCodeActions = (): HTMLElement[] => {
|
|
const codeHeader = codeBlock.querySelector<HTMLElement>('.code-header')
|
|
if (codeHeader) {
|
|
return Array.from(codeHeader.querySelectorAll<HTMLElement>('.action-btn'))
|
|
.filter(btn => btn !== pinnedFullscreenBtn)
|
|
}
|
|
return Array.from(codeBlock.querySelectorAll<HTMLElement>('.copy-icon-btn'))
|
|
}
|
|
|
|
const getCodeActions = (): HTMLElement[] => {
|
|
if (codeActionCache.length && codeActionCache.some(el => el.isConnected))
|
|
return codeActionCache
|
|
|
|
codeActionCache = collectCodeActions()
|
|
return codeActionCache
|
|
}
|
|
|
|
const setDiagramActionsVisible = (visible: boolean): void => {
|
|
diagramActionButtons.forEach((btn) => {
|
|
btn.style.display = visible ? '' : 'none'
|
|
})
|
|
}
|
|
|
|
const moveToActions = (elements: HTMLElement[]): void => {
|
|
elements.forEach((el) => {
|
|
if (!el || el.parentElement === actions)
|
|
return
|
|
|
|
movedActionRestore.set(el, { parent: el.parentElement as HTMLElement, next: el.nextSibling })
|
|
if (pinnedFullscreenBtn && pinnedFullscreenBtn.parentElement === actions) {
|
|
actions.insertBefore(el, pinnedFullscreenBtn)
|
|
}
|
|
else {
|
|
actions.appendChild(el)
|
|
}
|
|
})
|
|
}
|
|
|
|
const restoreMoved = (elements: HTMLElement[]): void => {
|
|
elements.forEach((el) => {
|
|
const restore = movedActionRestore.get(el)
|
|
if (!restore?.parent)
|
|
return
|
|
|
|
if (restore.next && restore.next.parentNode === restore.parent) {
|
|
restore.parent.insertBefore(el, restore.next)
|
|
}
|
|
else {
|
|
restore.parent.appendChild(el)
|
|
}
|
|
movedActionRestore.delete(el)
|
|
})
|
|
}
|
|
|
|
const switchTo = (target: 'diagram' | 'code'): void => {
|
|
const showDiagram = target === 'diagram'
|
|
diagramTabBtn.classList.toggle('active', showDiagram)
|
|
codeTabBtn.classList.toggle('active', !showDiagram)
|
|
diagramBlock.classList.toggle('active', showDiagram)
|
|
codeBlock.classList.toggle('active', !showDiagram)
|
|
|
|
const codeActions = getCodeActions()
|
|
if (showDiagram) {
|
|
setDiagramActionsVisible(true)
|
|
// Restore code actions back to code header in diagram mode.
|
|
restoreMoved(codeActions)
|
|
}
|
|
else {
|
|
setDiagramActionsVisible(false)
|
|
// Move code actions into tab action area while code tab is active.
|
|
moveToActions(codeActions)
|
|
}
|
|
}
|
|
|
|
const resolvePanzoom = (): PanzoomInstance | null => {
|
|
const svg = getActiveMermaidSvg(diagramBlock)
|
|
return svg && panzoomInstances ? (panzoomInstances.get(svg) ?? null) : null
|
|
}
|
|
|
|
diagramTabBtn.addEventListener('click', () => switchTo('diagram'), false)
|
|
codeTabBtn.addEventListener('click', () => switchTo('code'), false)
|
|
|
|
zoomInBtn?.addEventListener('click', () => {
|
|
const panzoom = resolvePanzoom()
|
|
if (!panzoom?.zoomIn)
|
|
return
|
|
try {
|
|
panzoom.zoomIn({ animate: true })
|
|
}
|
|
catch {
|
|
panzoom.zoomIn()
|
|
}
|
|
}, false)
|
|
|
|
zoomOutBtn?.addEventListener('click', () => {
|
|
const panzoom = resolvePanzoom()
|
|
if (!panzoom?.zoomOut)
|
|
return
|
|
try {
|
|
panzoom.zoomOut({ animate: true })
|
|
}
|
|
catch {
|
|
panzoom.zoomOut()
|
|
}
|
|
}, false)
|
|
|
|
resetBtn?.addEventListener('click', () => {
|
|
const panzoom = resolvePanzoom()
|
|
if (!panzoom?.reset)
|
|
return
|
|
try {
|
|
panzoom.reset({ animate: true })
|
|
}
|
|
catch {
|
|
panzoom.reset()
|
|
}
|
|
}, false)
|
|
|
|
downloadBtn?.addEventListener('click', () => {
|
|
const svg = getActiveMermaidSvg(diagramBlock)
|
|
if (!svg)
|
|
return
|
|
|
|
const clonedSvg = svg.cloneNode(true)
|
|
if (!(clonedSvg instanceof SVGElement))
|
|
return
|
|
|
|
// Remove style attribute (transform etc.) that may interfere with proper rendering in external viewers
|
|
clonedSvg.removeAttribute('style')
|
|
const xml = new XMLSerializer().serializeToString(clonedSvg)
|
|
const blob = new Blob([xml], { type: 'image/svg+xml;charset=utf-8' })
|
|
const url = URL.createObjectURL(blob)
|
|
const link = document.createElement('a')
|
|
const filename = diagramBlock.dataset.filename ?? 'mermaid.mmd'
|
|
|
|
link.href = url
|
|
link.download = `${filename.split('.')[0]}.svg`
|
|
document.body.appendChild(link)
|
|
link.click()
|
|
document.body.removeChild(link)
|
|
URL.revokeObjectURL(url)
|
|
}, false)
|
|
}
|
|
|
|
/** Initializes Mermaid diagram tab controls for newly discovered blocks. */
|
|
function initDiagramControls(): void {
|
|
const tabsList = document.querySelectorAll<HTMLElement>('.diagram-tabs[data-diagram="mermaid"]:not([data-diagram-init])')
|
|
tabsList.forEach((tabs) => {
|
|
void initSingleDiagramTabs(tabs)
|
|
})
|
|
}
|
|
|
|
/** Binds tab switch listener so Mermaid in tab panels renders after tab changes. */
|
|
function bindTabContainerChanged(): void {
|
|
document.addEventListener('tab-container-changed', (event: TabContainerChangedEvent) => {
|
|
const panel = event.panel ?? event.detail?.relatedTarget
|
|
if (!panel)
|
|
return
|
|
observeMermaidContainers(panel, true)
|
|
// Also schedule neutral layer render for the newly visible panel
|
|
const neutralNodes = panel.querySelectorAll<Element>('.mermaid-neutral')
|
|
neutralNodes.forEach((el) => {
|
|
if (!el.hasAttribute('data-processed'))
|
|
void renderMermaidElement(el, { theme: 'neutral', darkMode: false })
|
|
})
|
|
}, false)
|
|
}
|
|
|
|
/** Binds theme switch sync for pan/zoom transforms across Mermaid layers. */
|
|
function bindThemeSync(): void {
|
|
const getActivePanzoomSvg = (container: Element): SVGElement | null => {
|
|
const nodes = container.querySelectorAll('.mermaid, .mermaid-dark')
|
|
if (!nodes.length)
|
|
return null
|
|
|
|
const visible = Array.from(nodes).find((el) => {
|
|
const style = getComputedStyle(el)
|
|
if (style.display === 'none' || style.visibility === 'hidden')
|
|
return false
|
|
const rect = el.getBoundingClientRect()
|
|
return rect.width > 0 && rect.height > 0
|
|
})
|
|
|
|
return visible?.querySelector<SVGElement>('svg') ?? null
|
|
}
|
|
|
|
const safeGetTransformFromSvg = (svg: SVGElement | null): PanzoomTransform | null => {
|
|
const instance = svg ? panzoomInstances?.get(svg) : null
|
|
if (!instance?.getPan || !instance?.getScale)
|
|
return null
|
|
|
|
try {
|
|
const pan = instance.getPan()
|
|
const scale = instance.getScale()
|
|
if (pan && typeof scale === 'number')
|
|
return { x: pan.x, y: pan.y, scale }
|
|
}
|
|
catch {
|
|
return null
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
const sync = (nowDark: boolean): void => {
|
|
if (!mermaidPanzoomGroups)
|
|
return
|
|
|
|
document.querySelectorAll('.diagram-view .diagram-container').forEach((container) => {
|
|
const currentActive = getActivePanzoomSvg(container)
|
|
// Read transform from the layer that was visible before theme flip.
|
|
const fallbackSource = nowDark
|
|
? container.querySelector<SVGElement>('.mermaid svg')
|
|
: container.querySelector<SVGElement>('.mermaid-dark svg')
|
|
|
|
const groupSeed = currentActive ?? fallbackSource
|
|
if (!groupSeed)
|
|
return
|
|
|
|
const group = getMermaidPanzoomGroup(groupSeed)
|
|
if (!group)
|
|
return
|
|
|
|
const last = group.lastSvg
|
|
const sourceSvg = last?.isConnected ? last : (fallbackSource ?? currentActive)
|
|
const sourceTransform = safeGetTransformFromSvg(sourceSvg)
|
|
if (sourceTransform)
|
|
group.transform = sourceTransform
|
|
|
|
if (!group.transform)
|
|
return
|
|
|
|
if (currentActive) {
|
|
const instance = panzoomInstances?.get(currentActive)
|
|
applyPanzoomTransform(instance, group.transform)
|
|
}
|
|
})
|
|
}
|
|
|
|
eventBus.on('fixit:switch-theme', ({ detail }) => {
|
|
if (!detail?.isChanged)
|
|
return
|
|
|
|
sync(detail.isDark)
|
|
observeMermaidContainers(document, true)
|
|
})
|
|
}
|
|
|
|
/** Initializes Mermaid rendering, events, lazy loading and diagram controls. */
|
|
function initMermaid(): void {
|
|
const mermaidElements = document.querySelectorAll('.mermaid, .mermaid-dark, .mermaid-neutral')
|
|
if (!mermaidElements.length)
|
|
return
|
|
|
|
initDiagramControls()
|
|
observeMermaidContainers()
|
|
scheduleNeutralRender()
|
|
}
|
|
|
|
/** Binds Mermaid global listeners once per page lifecycle. */
|
|
function bindGlobalEventsOnce(): void {
|
|
if (hasBoundGlobalEvents)
|
|
return
|
|
|
|
hasBoundGlobalEvents = true
|
|
// eslint-disable-next-line no-console
|
|
console.log(
|
|
'%c💫 FixIt Mermaid',
|
|
'color: #FF3670; font-weight: bold; font-size: 16px; text-shadow: 1px 1px 2px rgba(0,0,0,0.1);',
|
|
)
|
|
bindThemeSync()
|
|
bindTabContainerChanged()
|
|
|
|
initMermaid()
|
|
eventBus.on('fixit:content-decrypted', initMermaid)
|
|
}
|
|
|
|
/**
|
|
* Initializes Mermaid runtime with externally loaded dependencies.
|
|
* Accepts the core Mermaid module + optional zenuml/layout loaders.
|
|
* @param runtime Runtime object with Mermaid module, config and optional extensions.
|
|
*/
|
|
export async function initMermaidRuntime(runtime: MermaidRuntime): Promise<void> {
|
|
mermaid = runtime.mermaid
|
|
config = runtime.config
|
|
|
|
const externalDiagrams: unknown[] = []
|
|
if (runtime.zenuml)
|
|
externalDiagrams.push(runtime.zenuml)
|
|
|
|
if (externalDiagrams.length && typeof mermaid.registerExternalDiagrams === 'function') {
|
|
try {
|
|
await mermaid.registerExternalDiagrams(externalDiagrams)
|
|
}
|
|
catch (error) {
|
|
console.warn('FixIt Mermaid registerExternalDiagrams failed:', error)
|
|
}
|
|
}
|
|
|
|
const loaders: unknown[] = []
|
|
runtime.loaders.forEach((item) => {
|
|
if (Array.isArray(item))
|
|
loaders.push(...item)
|
|
else
|
|
loaders.push(item)
|
|
})
|
|
|
|
if (loaders.length && typeof mermaid.registerLayoutLoaders === 'function') {
|
|
try {
|
|
mermaid.registerLayoutLoaders(loaders)
|
|
}
|
|
catch (error) {
|
|
console.warn('FixIt Mermaid registerLayoutLoaders failed:', error)
|
|
}
|
|
}
|
|
|
|
mermaid.startOnLoad = false
|
|
window.mermaid = mermaid
|
|
|
|
document.addEventListener('DOMContentLoaded', bindGlobalEventsOnce, { once: true })
|
|
if (document.readyState !== 'loading')
|
|
bindGlobalEventsOnce()
|
|
}
|
|
|
|
/**
|
|
* Unwraps ESM default export shape used by third-party CDN modules.
|
|
* @param mod Imported module object.
|
|
* @returns Default export when present, otherwise module itself.
|
|
*/
|
|
function unwrapModule<T>(mod: { default?: T } | T): T {
|
|
if (mod && typeof mod === 'object' && 'default' in mod) {
|
|
return (mod as { default?: T }).default as T
|
|
}
|
|
return mod as T
|
|
}
|
|
|
|
/**
|
|
* Bootstrap entry used by template-injected script.
|
|
* Loads Mermaid and optional extensions via dynamic import and hands runtime to initMermaidRuntime.
|
|
*/
|
|
export async function bootstrapMermaid(): Promise<void> {
|
|
config = window.config?.mermaid ?? {}
|
|
const mermaidSource = config.cdn || 'https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs'
|
|
const zenumlSource = config.zenuml || ''
|
|
const layoutLoaderSources = config.layoutLoaders || []
|
|
|
|
try {
|
|
// Mermaid core module is required; optional modules degrade gracefully.
|
|
const mermaidMod = await import(mermaidSource)
|
|
const mermaid = unwrapModule<MermaidRuntimeModule>(mermaidMod)
|
|
|
|
let zenuml: unknown
|
|
if (zenumlSource) {
|
|
try {
|
|
const zenumlMod = await import(zenumlSource)
|
|
zenuml = unwrapModule(zenumlMod)
|
|
}
|
|
catch (error) {
|
|
console.warn('FixIt Mermaid zenuml load failed:', error)
|
|
}
|
|
}
|
|
|
|
const loaded = await Promise.all(layoutLoaderSources.map(async (source) => {
|
|
try {
|
|
const loaderMod = await import(source)
|
|
return unwrapModule(loaderMod)
|
|
}
|
|
catch (error) {
|
|
console.warn('FixIt Mermaid layout loader load failed:', source, error)
|
|
return null
|
|
}
|
|
}))
|
|
|
|
const runtime: MermaidRuntime = {
|
|
mermaid,
|
|
config,
|
|
zenuml,
|
|
// Keep only successfully loaded optional loaders.
|
|
loaders: loaded.filter(item => item !== null),
|
|
}
|
|
await initMermaidRuntime(runtime)
|
|
}
|
|
catch (error) {
|
|
console.warn('FixIt Mermaid bootstrap failed:', error)
|
|
}
|
|
}
|