/**
* XFEStudio WebSite - Shared Documentation Logic
* Handles Image Lightbox, Code Highlighting/Copy/Label, Math Rendering (Katex), and Log Links.
*/
window.DocUtils = {
/**
* Initialize all documentation features on the given container.
* @param {HTMLElement | Document} container - The container to scan (default: document)
* @param {Object} options - Options for initialization
*/
init: function (container = document, options = {}) {
this.initImageLightbox(container);
this.initMermaid(container);
this.applyCodeFeatures(container);
this.initMathRendering(container);
if (container === document) {
this.initLogLinks();
}
},
// --- Mermaid Diagrams ---
initMermaid: function (container) {
this.pruneMermaidItems();
const mermaidBlocks = container.querySelectorAll(
'pre.mermaid, pre > code.language-mermaid, pre > code.lang-mermaid'
);
if (!mermaidBlocks.length) return;
const diagrams = [];
mermaidBlocks.forEach(block => {
const pre = block.matches('pre') ? block : block.parentElement;
if (!pre || pre.dataset.mermaidPrepared) return;
pre.dataset.mermaidPrepared = 'true';
const source = block.textContent || '';
const wrapper = document.createElement('div');
wrapper.className = 'mermaid-wrapper';
const toolbar = document.createElement('div');
toolbar.className = 'mermaid-toolbar';
const identity = document.createElement('div');
identity.className = 'mermaid-toolbar__identity';
identity.innerHTML = '<span class="mermaid-toolbar__mark"><i class="bi bi-diagram-3" aria-hidden="true"></i></span><span class="mermaid-toolbar__title">Mermaid</span>';
const actions = document.createElement('div');
actions.className = 'mermaid-toolbar__actions';
const zoomOutButton = this.createMermaidButton('zoom-out', 'bi-dash-lg', '缩小图表');
const zoomValue = document.createElement('output');
zoomValue.className = 'mermaid-zoom-value';
zoomValue.value = '100%';
zoomValue.textContent = '100%';
zoomValue.setAttribute('aria-label', '当前缩放比例');
const zoomInButton = this.createMermaidButton('zoom-in', 'bi-plus-lg', '放大图表');
const fitButton = this.createMermaidButton('fit', 'bi-aspect-ratio', '适应窗口');
const svgButton = this.createMermaidButton('download-svg', 'bi-filetype-svg', '导出 SVG');
const pngButton = this.createMermaidButton('download-png', 'bi-filetype-png', '导出 PNG');
const copyButton = this.createMermaidButton('copy', 'bi-copy', '复制 Mermaid 源码');
const expandButton = this.createMermaidButton('expand', 'bi-arrows-fullscreen', '全屏查看');
actions.append(
zoomOutButton,
zoomValue,
zoomInButton,
fitButton,
svgButton,
pngButton,
copyButton,
expandButton
);
toolbar.append(identity, actions);
const viewport = document.createElement('div');
viewport.className = 'mermaid-viewport';
viewport.tabIndex = 0;
viewport.setAttribute('aria-label', 'Mermaid 图表视图,可使用加号、减号和数字 0 调整缩放');
const canvas = document.createElement('div');
canvas.className = 'mermaid-canvas';
const diagram = document.createElement('div');
diagram.className = 'mermaid';
diagram.textContent = source;
canvas.appendChild(diagram);
viewport.appendChild(canvas);
wrapper.append(toolbar, viewport);
pre.replaceWith(wrapper);
const item = {
wrapper,
viewport,
canvas,
diagram,
source,
zoomValue,
buttons: {
zoomOut: zoomOutButton,
zoomIn: zoomInButton,
fit: fitButton,
svg: svgButton,
png: pngButton,
copy: copyButton,
expand: expandButton
},
naturalWidth: 0,
naturalHeight: 0,
scale: 1,
userAdjusted: false,
isNativeFullscreen: false
};
diagrams.push(item);
this.bindMermaidTools(item);
});
if (!diagrams.length) return;
this.mermaidItems ??= new Set();
diagrams.forEach(item => this.mermaidItems.add(item));
this.bindMermaidGlobalEvents();
this.loadMermaid()
.then(async mermaid => {
for (const item of diagrams) {
await this.renderMermaidWhenVisible(mermaid, item);
}
})
.catch(error => {
diagrams.forEach(item => {
this.showMermaidSource(item, '图表组件加载失败,已显示原始内容');
});
console.warn('Mermaid load failed:', error);
});
},
pruneMermaidItems: function () {
if (!this.mermaidItems) return;
this.mermaidItems.forEach(item => {
if (item.wrapper.isConnected) return;
item.resizeObserver?.disconnect();
item.visibilityObserver?.disconnect();
this.mermaidItems.delete(item);
});
},
isMermaidItemVisible: function (item) {
if (!item.wrapper.isConnected || item.wrapper.getClientRects().length === 0) return false;
const style = window.getComputedStyle(item.wrapper);
return style.display !== 'none' && style.visibility !== 'hidden' && item.viewport.clientWidth > 0;
},
renderMermaidWhenVisible: async function (mermaid, item) {
item.pendingMermaid = mermaid;
if (!this.isMermaidItemVisible(item)) {
item.wrapper.classList.remove('is-rendering', 'is-error');
item.wrapper.classList.add('is-pending');
item.wrapper.setAttribute('aria-busy', 'true');
this.setMermaidRenderControls(item, false);
this.observeMermaidVisibility(item);
return false;
}
item.visibilityObserver?.disconnect();
item.visibilityObserver = null;
return await this.queueMermaidRender(mermaid, item);
},
observeMermaidVisibility: function (item) {
if (item.visibilityObserver || !window.ResizeObserver) return;
item.visibilityObserver = new ResizeObserver(() => {
if (!item.pendingMermaid || !this.isMermaidItemVisible(item)) return;
const mermaid = item.pendingMermaid;
item.visibilityObserver?.disconnect();
item.visibilityObserver = null;
this.queueMermaidRender(mermaid, item).catch(error =>
console.warn('Deferred Mermaid render failed:', error));
});
item.visibilityObserver.observe(item.wrapper);
},
queueMermaidRender: function (mermaid, item) {
item.pendingMermaid = mermaid;
if (item.renderQueued) return item.renderPromise;
item.renderQueued = true;
const render = async () => {
if (!item.wrapper.isConnected) {
item.pendingMermaid = null;
return false;
}
if (!this.isMermaidItemVisible(item)) {
this.observeMermaidVisibility(item);
return false;
}
item.visibilityObserver?.disconnect();
item.visibilityObserver = null;
item.pendingMermaid = null;
await this.renderMermaidItem(mermaid, item);
return item.wrapper.classList.contains('is-rendered');
};
const previous = this.mermaidRenderQueue || Promise.resolve();
item.renderPromise = previous
.catch(error => console.warn('Previous Mermaid render failed:', error))
.then(render)
.finally(() => {
item.renderQueued = false;
item.renderPromise = null;
});
this.mermaidRenderQueue = item.renderPromise;
return item.renderPromise;
},
refreshMermaid: function (container = document) {
if (!this.mermaidItems) return;
this.mermaidItems.forEach(item => {
if (!item.wrapper.isConnected) return;
const isWithinContainer = container === document ||
container === item.wrapper ||
container.contains?.(item.wrapper);
if (!isWithinContainer || !this.isMermaidItemVisible(item)) return;
if (item.pendingMermaid) {
const mermaid = item.pendingMermaid;
item.visibilityObserver?.disconnect();
item.visibilityObserver = null;
this.queueMermaidRender(mermaid, item).catch(error =>
console.warn('Visible Mermaid render failed:', error));
} else if (item.svg && !item.userAdjusted) {
requestAnimationFrame(() => this.fitMermaid(item));
}
});
},
createMermaidButton: function (action, icon, label) {
const button = document.createElement('button');
button.className = 'mermaid-tool-button';
button.type = 'button';
button.dataset.mermaidAction = action;
button.title = label;
button.setAttribute('aria-label', label);
button.innerHTML = `<i class="bi ${icon}" aria-hidden="true"></i>`;
return button;
},
getMermaidConfig: function () {
const isDark = document.documentElement.classList.contains('dark');
const palette = isDark
? {
background: '#15131f', text: '#f4f0ff', muted: '#c8bedf',
primary: '#382d5c', primaryBorder: '#a78bfa',
secondary: '#173f42', secondaryBorder: '#49c6bd',
tertiary: '#4a3424', tertiaryBorder: '#f2a65a',
line: '#b9a7ea', note: '#4b4124', noteBorder: '#e7bd62',
accent: '#c4b5fd'
}
: {
background: '#fbfaff', text: '#2d2645', muted: '#615879',
primary: '#eee9ff', primaryBorder: '#7c5ce5',
secondary: '#ddf7f3', secondaryBorder: '#219a90',
tertiary: '#fff0d8', tertiaryBorder: '#d9822b',
line: '#7564a4', note: '#fff4c7', noteBorder: '#c99522',
accent: '#6f4bd8'
};
return {
startOnLoad: false,
securityLevel: 'strict',
suppressErrorRendering: true,
maxTextSize: 50000,
maxEdges: 500,
theme: 'base',
look: 'neo',
fontFamily: 'Inter, "Segoe UI", "Microsoft YaHei", system-ui, sans-serif',
themeVariables: {
background: palette.background,
textColor: palette.text,
primaryColor: palette.primary,
primaryTextColor: palette.text,
primaryBorderColor: palette.primaryBorder,
secondaryColor: palette.secondary,
secondaryTextColor: palette.text,
secondaryBorderColor: palette.secondaryBorder,
tertiaryColor: palette.tertiary,
tertiaryTextColor: palette.text,
tertiaryBorderColor: palette.tertiaryBorder,
lineColor: palette.line,
mainBkg: palette.primary,
nodeBorder: palette.primaryBorder,
clusterBkg: isDark ? '#211c33' : '#f4f1ff',
clusterBorder: palette.primaryBorder,
titleColor: palette.text,
edgeLabelBackground: palette.background,
actorBkg: palette.primary,
actorBorder: palette.primaryBorder,
actorTextColor: palette.text,
actorLineColor: palette.line,
signalColor: palette.line,
signalTextColor: palette.text,
labelBoxBkgColor: palette.secondary,
labelBoxBorderColor: palette.secondaryBorder,
labelTextColor: palette.text,
loopTextColor: palette.text,
noteBkgColor: palette.note,
noteBorderColor: palette.noteBorder,
noteTextColor: palette.text,
activationBkgColor: palette.tertiary,
activationBorderColor: palette.tertiaryBorder,
sequenceNumberColor: palette.background,
pie1: '#8067dc', pie2: '#35a99f', pie3: '#e69a48', pie4: '#d85b8b',
pie5: '#4f8fd8', pie6: '#7ebf65', pie7: '#b56bd2', pie8: '#db6b57',
pie9: '#5ab8d1', pie10: '#d8b448', pie11: '#8d83d8', pie12: '#4fb58a',
pieTitleTextColor: palette.text,
pieSectionTextColor: '#ffffff',
pieLegendTextColor: palette.text,
pieStrokeColor: palette.background,
pieOuterStrokeColor: palette.primaryBorder,
git0: '#8067dc', git1: '#35a99f', git2: '#e69a48', git3: '#d85b8b',
git4: '#4f8fd8', git5: '#7ebf65', git6: '#b56bd2', git7: '#db6b57',
cScale0: '#8067dc', cScale1: '#35a99f', cScale2: '#e69a48',
cScale3: '#d85b8b', cScale4: '#4f8fd8', cScale5: '#7ebf65',
cScale6: '#b56bd2', cScale7: '#db6b57', cScale8: '#5ab8d1',
cScale9: '#d8b448', cScale10: '#8d83d8', cScale11: '#4fb58a',
fontSize: '16px'
},
flowchart: {
curve: 'basis',
htmlLabels: false,
useMaxWidth: false,
nodeSpacing: 48,
rankSpacing: 58,
padding: 18
},
sequence: {
useMaxWidth: false,
wrap: true,
diagramMarginX: 48,
diagramMarginY: 24,
actorMargin: 64,
messageMargin: 42,
mirrorActors: true
},
gantt: {
useMaxWidth: false,
barHeight: 24,
barGap: 8,
topPadding: 48,
leftPadding: 80
}
};
},
measureMermaidSvg: function (svg) {
const viewBox = svg.viewBox?.baseVal;
const hasViewBox = viewBox && Number.isFinite(viewBox.width) && viewBox.width > 0 &&
Number.isFinite(viewBox.height) && viewBox.height > 0;
if (hasViewBox) {
const original = {
x: viewBox.x,
y: viewBox.y,
width: viewBox.width,
height: viewBox.height
};
const rendered = svg.getBoundingClientRect();
const scale = Math.min(
rendered.width / original.width,
rendered.height / original.height
);
let minX = original.x;
let minY = original.y;
let maxX = original.x + original.width;
let maxY = original.y + original.height;
if (Number.isFinite(scale) && scale > 0) {
const renderedContentWidth = original.width * scale;
const renderedContentHeight = original.height * scale;
const offsetX = rendered.left + (rendered.width - renderedContentWidth) / 2;
const offsetY = rendered.top + (rendered.height - renderedContentHeight) / 2;
let contentMinX = Infinity;
let contentMinY = Infinity;
let contentMaxX = -Infinity;
let contentMaxY = -Infinity;
svg.querySelectorAll(
'text, foreignObject, rect, circle, ellipse, line, path, polygon, polyline, image'
).forEach(element => {
// Mermaid 11 can leave non-mirrored sequence lifelines at y=2000.
// They are layout sentinels, not visible diagram content.
if (element.matches('.actor-line') ||
element.closest('defs, marker, clipPath, mask, pattern, symbol')) return;
const style = window.getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return;
const bounds = element.getBoundingClientRect();
if ((!bounds.width && !bounds.height) ||
!Number.isFinite(bounds.left) || !Number.isFinite(bounds.top) ||
!Number.isFinite(bounds.right) || !Number.isFinite(bounds.bottom)) return;
contentMinX = Math.min(contentMinX, original.x + (bounds.left - offsetX) / scale);
contentMinY = Math.min(contentMinY, original.y + (bounds.top - offsetY) / scale);
contentMaxX = Math.max(contentMaxX, original.x + (bounds.right - offsetX) / scale);
contentMaxY = Math.max(contentMaxY, original.y + (bounds.bottom - offsetY) / scale);
});
if (Number.isFinite(contentMinX) && Number.isFinite(contentMinY) &&
Number.isFinite(contentMaxX) && Number.isFinite(contentMaxY)) {
const safetyMargin = 16;
minX = Math.min(minX, contentMinX - safetyMargin);
minY = Math.min(minY, contentMinY - safetyMargin);
maxX = Math.max(maxX, contentMaxX + safetyMargin);
maxY = Math.max(maxY, contentMaxY + safetyMargin);
}
}
const width = Math.max(1, maxX - minX);
const height = Math.max(1, maxY - minY);
svg.setAttribute('viewBox', `${minX} ${minY} ${width} ${height}`);
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
return { width, height };
}
const measured = svg.getBoundingClientRect();
let minX = 0;
let minY = 0;
let maxX = Math.max(1, measured.width || 800);
let maxY = Math.max(1, measured.height || 450);
try {
let contentBounds;
try {
contentBounds = svg.getBBox({ fill: true, stroke: true, markers: true });
} catch {
contentBounds = svg.getBBox();
}
if (contentBounds && Number.isFinite(contentBounds.x) && Number.isFinite(contentBounds.y) &&
Number.isFinite(contentBounds.width) && contentBounds.width > 0 &&
Number.isFinite(contentBounds.height) && contentBounds.height > 0) {
const safetyMargin = 16;
const contentMinX = contentBounds.x - safetyMargin;
const contentMinY = contentBounds.y - safetyMargin;
const contentMaxX = contentBounds.x + contentBounds.width + safetyMargin;
const contentMaxY = contentBounds.y + contentBounds.height + safetyMargin;
minX = contentMinX;
minY = contentMinY;
maxX = contentMaxX;
maxY = contentMaxY;
}
} catch {
// Some SVG implementations do not expose a measurable bounding box.
}
const width = Math.max(1, maxX - minX);
const height = Math.max(1, maxY - minY);
svg.setAttribute('viewBox', `${minX} ${minY} ${width} ${height}`);
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
return { width, height };
},
renderMermaidItem: async function (mermaid, item) {
if (item.isRendering) return;
item.isRendering = true;
item.wrapper.classList.remove('is-pending', 'is-rendered', 'is-error');
item.wrapper.classList.add('is-rendering');
item.wrapper.setAttribute('aria-busy', 'true');
this.setMermaidRenderControls(item, false);
const diagram = document.createElement('div');
diagram.className = 'mermaid';
diagram.textContent = item.source;
item.diagram = diagram;
item.canvas.replaceChildren(diagram);
item.viewport.replaceChildren(item.canvas);
let renderStage = 'parse';
try {
if (document.fonts?.ready) await document.fonts.ready;
const parseResult = await mermaid.parse(item.source, { suppressErrors: true });
if (parseResult === false) throw new SyntaxError('Mermaid rejected the diagram source.');
renderStage = 'render';
await mermaid.run({
nodes: [diagram],
suppressErrors: true
});
if (document.fonts?.ready) await document.fonts.ready;
await new Promise(resolve => requestAnimationFrame(resolve));
const svg = diagram.querySelector('svg');
if (!svg) throw new Error('Mermaid did not produce an SVG.');
const dimensions = this.measureMermaidSvg(svg);
item.svg = svg;
item.naturalWidth = dimensions.width;
item.naturalHeight = dimensions.height;
item.scale = 1;
item.userAdjusted = false;
item.isDark = document.documentElement.classList.contains('dark');
svg.setAttribute('role', 'img');
svg.setAttribute('aria-label', 'Mermaid 图表');
svg.style.maxWidth = 'none';
svg.style.flex = '0 0 auto';
item.wrapper.classList.remove('is-rendering');
item.wrapper.classList.add('is-rendered');
item.wrapper.removeAttribute('aria-busy');
this.setMermaidRenderControls(item, true);
requestAnimationFrame(() => this.fitMermaid(item));
if (!item.resizeObserver && window.ResizeObserver) {
let lastWidth = item.viewport.clientWidth;
item.resizeObserver = new ResizeObserver(() => {
const width = item.viewport.clientWidth;
if (Math.abs(width - lastWidth) < 8) return;
lastWidth = width;
if (!item.userAdjusted) this.fitMermaid(item);
});
item.resizeObserver.observe(item.viewport);
}
} catch (error) {
this.showMermaidSource(
item,
renderStage === 'parse'
? 'Mermaid 语法有误,已显示原始内容'
: 'Mermaid 图表渲染失败,已显示原始内容');
console.warn('Mermaid render failed:', error);
} finally {
item.isRendering = false;
}
},
setMermaidRenderControls: function (item, enabled) {
[item.buttons.zoomOut, item.buttons.zoomIn, item.buttons.fit, item.buttons.svg, item.buttons.png]
.forEach(button => { button.disabled = !enabled; });
},
setMermaidScale: function (item, requestedScale, userAdjusted = true) {
if (!item.svg || !item.naturalWidth || !item.naturalHeight) return;
const scale = Math.min(4, Math.max(0.1, requestedScale));
const oldScale = item.scale || 1;
const centerX = item.viewport.scrollLeft + item.viewport.clientWidth / 2;
const centerY = item.viewport.scrollTop + item.viewport.clientHeight / 2;
item.scale = scale;
item.userAdjusted = userAdjusted;
const renderedWidth = Math.max(1, item.naturalWidth * scale);
const renderedHeight = Math.max(1, item.naturalHeight * scale);
item.svg.style.width = `${renderedWidth}px`;
item.svg.style.height = `${renderedHeight}px`;
item.diagram.style.width = `${renderedWidth}px`;
item.diagram.style.height = `${renderedHeight}px`;
item.zoomValue.value = `${Math.round(scale * 100)}%`;
item.zoomValue.textContent = item.zoomValue.value;
item.buttons.zoomOut.disabled = scale <= 0.101;
item.buttons.zoomIn.disabled = scale >= 3.999;
if (userAdjusted) {
requestAnimationFrame(() => {
const ratio = scale / oldScale;
item.viewport.scrollLeft = centerX * ratio - item.viewport.clientWidth / 2;
item.viewport.scrollTop = centerY * ratio - item.viewport.clientHeight / 2;
});
}
},
fitMermaid: function (item) {
if (!item.svg || !item.naturalWidth) return;
const canvasStyle = window.getComputedStyle(item.canvas);
const horizontalPadding = (parseFloat(canvasStyle.paddingLeft) || 0) +
(parseFloat(canvasStyle.paddingRight) || 0);
const availableWidth = Math.max(220, item.viewport.clientWidth - horizontalPadding);
const scale = Math.min(1, availableWidth / item.naturalWidth);
this.setMermaidScale(item, scale, false);
item.viewport.scrollLeft = 0;
item.viewport.scrollTop = 0;
},
bindMermaidTools: function (item) {
item.buttons.zoomOut.addEventListener('click', () =>
this.setMermaidScale(item, item.scale / 1.2));
item.buttons.zoomIn.addEventListener('click', () =>
this.setMermaidScale(item, item.scale * 1.2));
item.buttons.fit.addEventListener('click', () => this.fitMermaid(item));
item.buttons.expand.addEventListener('click', () => this.toggleMermaidExpanded(item));
item.buttons.copy.addEventListener('click', async () => {
try {
await this.copyMermaidSource(item.source);
this.flashMermaidButton(item.buttons.copy, 'bi-check-lg', '已复制');
} catch {
this.flashMermaidButton(item.buttons.copy, 'bi-exclamation-lg', '复制失败', true);
}
});
item.buttons.svg.addEventListener('click', () => this.downloadMermaidSvg(item));
item.buttons.png.addEventListener('click', async () => {
item.buttons.png.disabled = true;
try {
await this.downloadMermaidPng(item);
this.flashMermaidButton(item.buttons.png, 'bi-check-lg', 'PNG 已导出');
} catch (error) {
this.flashMermaidButton(item.buttons.png, 'bi-exclamation-lg', 'PNG 导出失败', true);
console.warn('Mermaid PNG export failed:', error);
} finally {
item.buttons.png.disabled = false;
}
});
item.viewport.addEventListener('dblclick', event => {
if (!event.target.closest('a')) this.fitMermaid(item);
});
item.viewport.addEventListener('keydown', event => {
if (event.key === '+' || event.key === '=') {
event.preventDefault();
this.setMermaidScale(item, item.scale * 1.2);
} else if (event.key === '-') {
event.preventDefault();
this.setMermaidScale(item, item.scale / 1.2);
} else if (event.key === '0') {
event.preventDefault();
this.fitMermaid(item);
}
});
let dragState = null;
item.viewport.addEventListener('pointerdown', event => {
if (event.pointerType !== 'mouse' || event.button !== 0 || event.target.closest('a')) return;
if (item.viewport.scrollWidth <= item.viewport.clientWidth &&
item.viewport.scrollHeight <= item.viewport.clientHeight) return;
dragState = {
x: event.clientX,
y: event.clientY,
left: item.viewport.scrollLeft,
top: item.viewport.scrollTop
};
item.viewport.setPointerCapture(event.pointerId);
item.viewport.classList.add('is-panning');
event.preventDefault();
});
item.viewport.addEventListener('pointermove', event => {
if (!dragState) return;
item.viewport.scrollLeft = dragState.left - (event.clientX - dragState.x);
item.viewport.scrollTop = dragState.top - (event.clientY - dragState.y);
});
const endPan = () => {
dragState = null;
item.viewport.classList.remove('is-panning');
};
item.viewport.addEventListener('pointerup', endPan);
item.viewport.addEventListener('pointercancel', endPan);
},
bindMermaidGlobalEvents: function () {
if (this.mermaidGlobalEventsBound) return;
this.mermaidGlobalEventsBound = true;
document.addEventListener('fullscreenchange', () => {
this.mermaidItems?.forEach(item => {
if (!item.wrapper.isConnected) return;
const isFullscreen = document.fullscreenElement === item.wrapper;
if (isFullscreen !== item.isNativeFullscreen) {
item.isNativeFullscreen = isFullscreen;
requestAnimationFrame(() => this.fitMermaid(item));
}
this.updateMermaidExpandButton(item);
});
});
document.addEventListener('keydown', event => {
if (event.key !== 'Escape') return;
const expanded = document.querySelector('.mermaid-wrapper.is-expanded');
if (!expanded) return;
expanded.classList.remove('is-expanded');
document.body.classList.remove('mermaid-expanded');
const item = [...(this.mermaidItems || [])].find(value => value.wrapper === expanded);
if (item) {
this.updateMermaidExpandButton(item);
requestAnimationFrame(() => this.fitMermaid(item));
}
});
const refreshVisibleContent = () =>
requestAnimationFrame(() => this.refreshMermaid());
document.addEventListener('shown.bs.tab', refreshVisibleContent);
document.addEventListener('shown.bs.collapse', refreshVisibleContent);
document.addEventListener('shown.bs.modal', refreshVisibleContent);
document.addEventListener('shown.bs.offcanvas', refreshVisibleContent);
document.addEventListener('toggle', refreshVisibleContent, true);
this.mermaidTheme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
this.mermaidThemeObserver = new MutationObserver(() => {
const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
if (theme === this.mermaidTheme) return;
this.mermaidTheme = theme;
clearTimeout(this.mermaidThemeTimer);
this.mermaidThemeTimer = setTimeout(() => this.rerenderMermaidForTheme(), 80);
});
this.mermaidThemeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
},
rerenderMermaidForTheme: async function () {
try {
this.pruneMermaidItems();
const mermaid = await this.loadMermaid();
mermaid.initialize(this.getMermaidConfig());
for (const item of this.mermaidItems || []) {
if (item.wrapper.isConnected) await this.renderMermaidWhenVisible(mermaid, item);
}
} catch (error) {
console.warn('Mermaid theme refresh failed:', error);
}
},
loadMermaid: function () {
if (!this.mermaidPromise) {
this.mermaidPromise = new Promise((resolve, reject) => {
const initialize = () => {
if (!window.mermaid) {
reject(new Error('The local Mermaid library did not initialize.'));
return;
}
window.mermaid.initialize(this.getMermaidConfig());
resolve(window.mermaid);
};
if (window.mermaid) {
initialize();
return;
}
const existingScript = document.querySelector('script[data-mermaid-library]');
if (existingScript) {
existingScript.addEventListener('load', initialize, { once: true });
existingScript.addEventListener(
'error',
() => reject(new Error('The local Mermaid library could not be loaded.')),
{ once: true }
);
return;
}
const script = document.createElement('script');
script.src = 'https://xfe-resources.invalid/Documents/Mermaid.min.js';
script.dataset.mermaidLibrary = 'true';
script.addEventListener('load', initialize, { once: true });
script.addEventListener(
'error',
() => reject(new Error('The local Mermaid library could not be loaded.')),
{ once: true }
);
document.head.appendChild(script);
});
}
return this.mermaidPromise;
},
toggleMermaidExpanded: async function (item) {
if (document.fullscreenElement === item.wrapper) {
await document.exitFullscreen();
return;
}
if (item.wrapper.classList.contains('is-expanded')) {
item.wrapper.classList.remove('is-expanded');
document.body.classList.remove('mermaid-expanded');
this.updateMermaidExpandButton(item);
requestAnimationFrame(() => this.fitMermaid(item));
return;
}
if (item.wrapper.requestFullscreen) {
try {
await item.wrapper.requestFullscreen();
return;
} catch {
// Fall back to an in-page overlay when native fullscreen is unavailable.
}
}
document.querySelectorAll('.mermaid-wrapper.is-expanded').forEach(wrapper =>
wrapper.classList.remove('is-expanded'));
item.wrapper.classList.add('is-expanded');
document.body.classList.add('mermaid-expanded');
this.updateMermaidExpandButton(item);
requestAnimationFrame(() => this.fitMermaid(item));
},
updateMermaidExpandButton: function (item) {
const expanded = document.fullscreenElement === item.wrapper ||
item.wrapper.classList.contains('is-expanded');
const icon = item.buttons.expand.querySelector('i');
if (icon) icon.className = `bi ${expanded ? 'bi-fullscreen-exit' : 'bi-arrows-fullscreen'}`;
item.buttons.expand.classList.toggle('is-active', expanded);
item.buttons.expand.title = expanded ? '退出全屏' : '全屏查看';
item.buttons.expand.setAttribute('aria-label', item.buttons.expand.title);
item.buttons.expand.setAttribute('aria-pressed', String(expanded));
},
copyMermaidSource: async function (source) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(source);
return;
}
const textarea = document.createElement('textarea');
textarea.value = source;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
textarea.remove();
},
flashMermaidButton: function (button, iconClass, message, isError = false) {
const icon = button.querySelector('i');
const originalIcon = icon?.className;
const originalTitle = button.title;
if (icon) icon.className = `bi ${iconClass}`;
button.classList.toggle('is-error', isError);
button.classList.toggle('is-success', !isError);
button.title = message;
button.setAttribute('aria-label', message);
setTimeout(() => {
if (icon && originalIcon) icon.className = originalIcon;
button.classList.remove('is-error', 'is-success');
button.title = originalTitle;
button.setAttribute('aria-label', originalTitle);
}, 1600);
},
serializeMermaidSvg: function (item, rasterSafe = false) {
if (!item.svg) throw new Error('No rendered Mermaid SVG is available.');
const dimensions = this.measureMermaidSvg(item.svg);
item.naturalWidth = dimensions.width;
item.naturalHeight = dimensions.height;
const clone = item.svg.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
clone.setAttribute('width', String(Math.ceil(item.naturalWidth)));
clone.setAttribute('height', String(Math.ceil(item.naturalHeight)));
clone.style.width = '';
clone.style.height = '';
clone.style.maxWidth = '';
// Chromium marks canvases as tainted when an SVG contains foreignObject labels.
// Replace those HTML labels with plain SVG text only for raster export.
if (rasterSafe) {
clone.querySelectorAll('foreignObject').forEach(foreignObject => {
const namespace = 'http://www.w3.org/2000/svg';
const text = document.createElementNS(namespace, 'text');
const x = Number.parseFloat(foreignObject.getAttribute('x') || '0');
const y = Number.parseFloat(foreignObject.getAttribute('y') || '0');
const width = Number.parseFloat(foreignObject.getAttribute('width') || '0');
const height = Number.parseFloat(foreignObject.getAttribute('height') || '0');
text.setAttribute('x', String(x + width / 2));
text.setAttribute('y', String(y + height / 2));
text.setAttribute('text-anchor', 'middle');
text.setAttribute('dominant-baseline', 'central');
text.setAttribute('font-family', 'Segoe UI, Microsoft YaHei, sans-serif');
text.setAttribute('font-size', '14');
text.setAttribute('font-weight', '600');
text.setAttribute('fill', item.isDark ? '#f4f0ff' : '#2d2645');
text.textContent = (foreignObject.textContent || '').replace(/\s+/g, ' ').trim();
foreignObject.replaceWith(text);
});
}
return new XMLSerializer().serializeToString(clone);
},
downloadBlob: function (blob, fileName) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
},
downloadMermaidSvg: function (item) {
try {
const source = this.serializeMermaidSvg(item);
this.downloadBlob(
new Blob([source], { type: 'image/svg+xml;charset=utf-8' }),
'mermaid-diagram.svg'
);
this.flashMermaidButton(item.buttons.svg, 'bi-check-lg', 'SVG 已导出');
} catch (error) {
this.flashMermaidButton(item.buttons.svg, 'bi-exclamation-lg', 'SVG 导出失败', true);
console.warn('Mermaid SVG export failed:', error);
}
},
downloadMermaidPng: async function (item) {
const svgSource = this.serializeMermaidSvg(item, true);
const svgUrl = URL.createObjectURL(new Blob([svgSource], { type: 'image/svg+xml;charset=utf-8' }));
try {
const image = new Image();
image.src = svgUrl;
await image.decode();
const maxDimension = 8192;
const density = Math.min(
2,
maxDimension / item.naturalWidth,
maxDimension / item.naturalHeight
);
const width = Math.max(1, Math.round(item.naturalWidth * density));
const height = Math.max(1, Math.round(item.naturalHeight * density));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) throw new Error('Canvas is unavailable.');
context.fillStyle = item.isDark ? '#15131f' : '#fbfaff';
context.fillRect(0, 0, width, height);
context.drawImage(image, 0, 0, width, height);
const blob = await new Promise((resolve, reject) => {
canvas.toBlob(value => value ? resolve(value) : reject(new Error('PNG encoding failed.')), 'image/png');
});
this.downloadBlob(blob, 'mermaid-diagram.png');
} finally {
URL.revokeObjectURL(svgUrl);
}
},
showMermaidSource: function (item, message) {
item.wrapper.classList.remove('is-pending', 'is-rendering', 'is-rendered');
item.wrapper.classList.add('is-error');
item.wrapper.removeAttribute('aria-busy');
item.pendingMermaid = null;
item.visibilityObserver?.disconnect();
item.visibilityObserver = null;
item.svg = null;
item.naturalWidth = 0;
item.naturalHeight = 0;
this.setMermaidRenderControls(item, false);
const title = document.createElement('div');
title.className = 'mermaid-error-title';
title.textContent = message;
const pre = document.createElement('pre');
pre.className = 'mermaid-source';
const code = document.createElement('code');
code.textContent = item.source;
pre.appendChild(code);
item.viewport.replaceChildren(title, pre);
},
// --- Image Lightbox ---
initImageLightbox: function (container) {
const images = container.querySelectorAll('.markdown-body img');
images.forEach(img => {
// Linked images (for example status badges) should keep normal link behavior.
if (img.closest('a[href]')) {
img.style.cursor = '';
return;
}
img.style.cursor = 'zoom-in';
// Prevent double binding
if (img.dataset.lightboxBound) return;
img.dataset.lightboxBound = "true";
img.addEventListener('click', function () {
DocUtils.openImageModal(this.src);
});
});
},
openImageModal: function (src) {
const modalEl = document.getElementById('imageModal');
if (!modalEl) return;
const modalImage = document.getElementById('modalImage');
if (modalImage) modalImage.src = src;
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show();
},
// --- Code Features (Highlight, Border, Label, Copy) ---
applyCodeFeatures: function (container) {
// 1. Syntax Highlighting
if (typeof hljs !== 'undefined') {
if (container === document) {
// Main page load
hljs.highlightAll();
} else {
// Dynamic content
container.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
});
}
}
// 2. Shared toolbar, line numbers and code viewport.
window.XFECodeBlocks?.enhance(container);
},
// --- Math Rendering (Katex) ---
initMathRendering: function (container) {
if (typeof renderMathInElement === 'function') {
// Note: renderMathInElement works on the element itself, not just querySelectorAll
// If container is document, pass document.body
const target = container === document ? document.body : container;
renderMathInElement(target, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true }
],
throwOnError: false
});
}
},
// --- Log Links ---
initLogLinks: function () {
if (document.body.dataset.logLinkBound) return;
document.body.dataset.logLinkBound = "true";
document.addEventListener('click', function (e) {
if (e.target.classList.contains('hljs-log-link')) {
if (e.ctrlKey) {
const url = e.target.innerText;
if (url) {
try {
const validUrl = new URL(url);
window.open('/Redirect?url=' + encodeURIComponent(validUrl.toString()), '_blank');
} catch (err) {
console.error('Invalid URL:', url);
}
}
}
}
});
}
};
// Auto-initialize on DOMContentLoaded
document.addEventListener('DOMContentLoaded', () => {
// Only run if not disabled (allow pages to opt-out or manual control)
if (!document.body.dataset.manualDocInit) {
DocUtils.init();
}
});
/**
* XFEStudio WebSite - Shared Documentation Logic
* Handles Image Lightbox, Code Highlighting/Copy/Label, Math Rendering (Katex), and Log Links.
*/
window.DocUtils = {
/**
* Initialize all documentation features on the given container.
* @param {HTMLElement | Document} container - The container to scan (default: document)
* @param {Object} options - Options for initialization
*/
init: function (container = document, options = {}) {
this.initImageLightbox(container);
this.initMermaid(container);
this.applyCodeFeatures(container);
this.initMathRendering(container);
if (container === document) {
this.initLogLinks();
}
},
// --- Mermaid Diagrams ---
initMermaid: function (container) {
this.pruneMermaidItems();
const mermaidBlocks = container.querySelectorAll(
'pre.mermaid, pre > code.language-mermaid, pre > code.lang-mermaid'
);
if (!mermaidBlocks.length) return;
const diagrams = [];
mermaidBlocks.forEach(block => {
const pre = block.matches('pre') ? block : block.parentElement;
if (!pre || pre.dataset.mermaidPrepared) return;
pre.dataset.mermaidPrepared = 'true';
const source = block.textContent || '';
const wrapper = document.createElement('div');
wrapper.className = 'mermaid-wrapper';
const toolbar = document.createElement('div');
toolbar.className = 'mermaid-toolbar';
const identity = document.createElement('div');
identity.className = 'mermaid-toolbar__identity';
identity.innerHTML = '<span class="mermaid-toolbar__mark"><i class="bi bi-diagram-3" aria-hidden="true"></i></span><span class="mermaid-toolbar__title">Mermaid</span>';
const actions = document.createElement('div');
actions.className = 'mermaid-toolbar__actions';
const zoomOutButton = this.createMermaidButton('zoom-out', 'bi-dash-lg', '缩小图表');
const zoomValue = document.createElement('output');
zoomValue.className = 'mermaid-zoom-value';
zoomValue.value = '100%';
zoomValue.textContent = '100%';
zoomValue.setAttribute('aria-label', '当前缩放比例');
const zoomInButton = this.createMermaidButton('zoom-in', 'bi-plus-lg', '放大图表');
const fitButton = this.createMermaidButton('fit', 'bi-aspect-ratio', '适应窗口');
const svgButton = this.createMermaidButton('download-svg', 'bi-filetype-svg', '导出 SVG');
const pngButton = this.createMermaidButton('download-png', 'bi-filetype-png', '导出 PNG');
const copyButton = this.createMermaidButton('copy', 'bi-copy', '复制 Mermaid 源码');
const expandButton = this.createMermaidButton('expand', 'bi-arrows-fullscreen', '全屏查看');
actions.append(
zoomOutButton,
zoomValue,
zoomInButton,
fitButton,
svgButton,
pngButton,
copyButton,
expandButton
);
toolbar.append(identity, actions);
const viewport = document.createElement('div');
viewport.className = 'mermaid-viewport';
viewport.tabIndex = 0;
viewport.setAttribute('aria-label', 'Mermaid 图表视图,可使用加号、减号和数字 0 调整缩放');
const canvas = document.createElement('div');
canvas.className = 'mermaid-canvas';
const diagram = document.createElement('div');
diagram.className = 'mermaid';
diagram.textContent = source;
canvas.appendChild(diagram);
viewport.appendChild(canvas);
wrapper.append(toolbar, viewport);
pre.replaceWith(wrapper);
const item = {
wrapper,
viewport,
canvas,
diagram,
source,
zoomValue,
buttons: {
zoomOut: zoomOutButton,
zoomIn: zoomInButton,
fit: fitButton,
svg: svgButton,
png: pngButton,
copy: copyButton,
expand: expandButton
},
naturalWidth: 0,
naturalHeight: 0,
scale: 1,
userAdjusted: false,
isNativeFullscreen: false
};
diagrams.push(item);
this.bindMermaidTools(item);
});
if (!diagrams.length) return;
this.mermaidItems ??= new Set();
diagrams.forEach(item => this.mermaidItems.add(item));
this.bindMermaidGlobalEvents();
this.loadMermaid()
.then(async mermaid => {
for (const item of diagrams) {
await this.renderMermaidWhenVisible(mermaid, item);
}
})
.catch(error => {
diagrams.forEach(item => {
this.showMermaidSource(item, '图表组件加载失败,已显示原始内容');
});
console.warn('Mermaid load failed:', error);
});
},
pruneMermaidItems: function () {
if (!this.mermaidItems) return;
this.mermaidItems.forEach(item => {
if (item.wrapper.isConnected) return;
item.resizeObserver?.disconnect();
item.visibilityObserver?.disconnect();
this.mermaidItems.delete(item);
});
},
isMermaidItemVisible: function (item) {
if (!item.wrapper.isConnected || item.wrapper.getClientRects().length === 0) return false;
const style = window.getComputedStyle(item.wrapper);
return style.display !== 'none' && style.visibility !== 'hidden' && item.viewport.clientWidth > 0;
},
renderMermaidWhenVisible: async function (mermaid, item) {
item.pendingMermaid = mermaid;
if (!this.isMermaidItemVisible(item)) {
item.wrapper.classList.remove('is-rendering', 'is-error');
item.wrapper.classList.add('is-pending');
item.wrapper.setAttribute('aria-busy', 'true');
this.setMermaidRenderControls(item, false);
this.observeMermaidVisibility(item);
return false;
}
item.visibilityObserver?.disconnect();
item.visibilityObserver = null;
return await this.queueMermaidRender(mermaid, item);
},
observeMermaidVisibility: function (item) {
if (item.visibilityObserver || !window.ResizeObserver) return;
item.visibilityObserver = new ResizeObserver(() => {
if (!item.pendingMermaid || !this.isMermaidItemVisible(item)) return;
const mermaid = item.pendingMermaid;
item.visibilityObserver?.disconnect();
item.visibilityObserver = null;
this.queueMermaidRender(mermaid, item).catch(error =>
console.warn('Deferred Mermaid render failed:', error));
});
item.visibilityObserver.observe(item.wrapper);
},
queueMermaidRender: function (mermaid, item) {
item.pendingMermaid = mermaid;
if (item.renderQueued) return item.renderPromise;
item.renderQueued = true;
const render = async () => {
if (!item.wrapper.isConnected) {
item.pendingMermaid = null;
return false;
}
if (!this.isMermaidItemVisible(item)) {
this.observeMermaidVisibility(item);
return false;
}
item.visibilityObserver?.disconnect();
item.visibilityObserver = null;
item.pendingMermaid = null;
await this.renderMermaidItem(mermaid, item);
return item.wrapper.classList.contains('is-rendered');
};
const previous = this.mermaidRenderQueue || Promise.resolve();
item.renderPromise = previous
.catch(error => console.warn('Previous Mermaid render failed:', error))
.then(render)
.finally(() => {
item.renderQueued = false;
item.renderPromise = null;
});
this.mermaidRenderQueue = item.renderPromise;
return item.renderPromise;
},
refreshMermaid: function (container = document) {
if (!this.mermaidItems) return;
this.mermaidItems.forEach(item => {
if (!item.wrapper.isConnected) return;
const isWithinContainer = container === document ||
container === item.wrapper ||
container.contains?.(item.wrapper);
if (!isWithinContainer || !this.isMermaidItemVisible(item)) return;
if (item.pendingMermaid) {
const mermaid = item.pendingMermaid;
item.visibilityObserver?.disconnect();
item.visibilityObserver = null;
this.queueMermaidRender(mermaid, item).catch(error =>
console.warn('Visible Mermaid render failed:', error));
} else if (item.svg && !item.userAdjusted) {
requestAnimationFrame(() => this.fitMermaid(item));
}
});
},
createMermaidButton: function (action, icon, label) {
const button = document.createElement('button');
button.className = 'mermaid-tool-button';
button.type = 'button';
button.dataset.mermaidAction = action;
button.title = label;
button.setAttribute('aria-label', label);
button.innerHTML = `<i class="bi ${icon}" aria-hidden="true"></i>`;
return button;
},
getMermaidConfig: function () {
const isDark = document.documentElement.classList.contains('dark');
const palette = isDark
? {
background: '#15131f', text: '#f4f0ff', muted: '#c8bedf',
primary: '#382d5c', primaryBorder: '#a78bfa',
secondary: '#173f42', secondaryBorder: '#49c6bd',
tertiary: '#4a3424', tertiaryBorder: '#f2a65a',
line: '#b9a7ea', note: '#4b4124', noteBorder: '#e7bd62',
accent: '#c4b5fd'
}
: {
background: '#fbfaff', text: '#2d2645', muted: '#615879',
primary: '#eee9ff', primaryBorder: '#7c5ce5',
secondary: '#ddf7f3', secondaryBorder: '#219a90',
tertiary: '#fff0d8', tertiaryBorder: '#d9822b',
line: '#7564a4', note: '#fff4c7', noteBorder: '#c99522',
accent: '#6f4bd8'
};
return {
startOnLoad: false,
securityLevel: 'strict',
suppressErrorRendering: true,
maxTextSize: 50000,
maxEdges: 500,
theme: 'base',
look: 'neo',
fontFamily: 'Inter, "Segoe UI", "Microsoft YaHei", system-ui, sans-serif',
themeVariables: {
background: palette.background,
textColor: palette.text,
primaryColor: palette.primary,
primaryTextColor: palette.text,
primaryBorderColor: palette.primaryBorder,
secondaryColor: palette.secondary,
secondaryTextColor: palette.text,
secondaryBorderColor: palette.secondaryBorder,
tertiaryColor: palette.tertiary,
tertiaryTextColor: palette.text,
tertiaryBorderColor: palette.tertiaryBorder,
lineColor: palette.line,
mainBkg: palette.primary,
nodeBorder: palette.primaryBorder,
clusterBkg: isDark ? '#211c33' : '#f4f1ff',
clusterBorder: palette.primaryBorder,
titleColor: palette.text,
edgeLabelBackground: palette.background,
actorBkg: palette.primary,
actorBorder: palette.primaryBorder,
actorTextColor: palette.text,
actorLineColor: palette.line,
signalColor: palette.line,
signalTextColor: palette.text,
labelBoxBkgColor: palette.secondary,
labelBoxBorderColor: palette.secondaryBorder,
labelTextColor: palette.text,
loopTextColor: palette.text,
noteBkgColor: palette.note,
noteBorderColor: palette.noteBorder,
noteTextColor: palette.text,
activationBkgColor: palette.tertiary,
activationBorderColor: palette.tertiaryBorder,
sequenceNumberColor: palette.background,
pie1: '#8067dc', pie2: '#35a99f', pie3: '#e69a48', pie4: '#d85b8b',
pie5: '#4f8fd8', pie6: '#7ebf65', pie7: '#b56bd2', pie8: '#db6b57',
pie9: '#5ab8d1', pie10: '#d8b448', pie11: '#8d83d8', pie12: '#4fb58a',
pieTitleTextColor: palette.text,
pieSectionTextColor: '#ffffff',
pieLegendTextColor: palette.text,
pieStrokeColor: palette.background,
pieOuterStrokeColor: palette.primaryBorder,
git0: '#8067dc', git1: '#35a99f', git2: '#e69a48', git3: '#d85b8b',
git4: '#4f8fd8', git5: '#7ebf65', git6: '#b56bd2', git7: '#db6b57',
cScale0: '#8067dc', cScale1: '#35a99f', cScale2: '#e69a48',
cScale3: '#d85b8b', cScale4: '#4f8fd8', cScale5: '#7ebf65',
cScale6: '#b56bd2', cScale7: '#db6b57', cScale8: '#5ab8d1',
cScale9: '#d8b448', cScale10: '#8d83d8', cScale11: '#4fb58a',
fontSize: '16px'
},
flowchart: {
curve: 'basis',
htmlLabels: false,
useMaxWidth: false,
nodeSpacing: 48,
rankSpacing: 58,
padding: 18
},
sequence: {
useMaxWidth: false,
wrap: true,
diagramMarginX: 48,
diagramMarginY: 24,
actorMargin: 64,
messageMargin: 42,
mirrorActors: true
},
gantt: {
useMaxWidth: false,
barHeight: 24,
barGap: 8,
topPadding: 48,
leftPadding: 80
}
};
},
measureMermaidSvg: function (svg) {
const viewBox = svg.viewBox?.baseVal;
const hasViewBox = viewBox && Number.isFinite(viewBox.width) && viewBox.width > 0 &&
Number.isFinite(viewBox.height) && viewBox.height > 0;
if (hasViewBox) {
const original = {
x: viewBox.x,
y: viewBox.y,
width: viewBox.width,
height: viewBox.height
};
const rendered = svg.getBoundingClientRect();
const scale = Math.min(
rendered.width / original.width,
rendered.height / original.height
);
let minX = original.x;
let minY = original.y;
let maxX = original.x + original.width;
let maxY = original.y + original.height;
if (Number.isFinite(scale) && scale > 0) {
const renderedContentWidth = original.width * scale;
const renderedContentHeight = original.height * scale;
const offsetX = rendered.left + (rendered.width - renderedContentWidth) / 2;
const offsetY = rendered.top + (rendered.height - renderedContentHeight) / 2;
let contentMinX = Infinity;
let contentMinY = Infinity;
let contentMaxX = -Infinity;
let contentMaxY = -Infinity;
svg.querySelectorAll(
'text, foreignObject, rect, circle, ellipse, line, path, polygon, polyline, image'
).forEach(element => {
// Mermaid 11 can leave non-mirrored sequence lifelines at y=2000.
// They are layout sentinels, not visible diagram content.
if (element.matches('.actor-line') ||
element.closest('defs, marker, clipPath, mask, pattern, symbol')) return;
const style = window.getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return;
const bounds = element.getBoundingClientRect();
if ((!bounds.width && !bounds.height) ||
!Number.isFinite(bounds.left) || !Number.isFinite(bounds.top) ||
!Number.isFinite(bounds.right) || !Number.isFinite(bounds.bottom)) return;
contentMinX = Math.min(contentMinX, original.x + (bounds.left - offsetX) / scale);
contentMinY = Math.min(contentMinY, original.y + (bounds.top - offsetY) / scale);
contentMaxX = Math.max(contentMaxX, original.x + (bounds.right - offsetX) / scale);
contentMaxY = Math.max(contentMaxY, original.y + (bounds.bottom - offsetY) / scale);
});
if (Number.isFinite(contentMinX) && Number.isFinite(contentMinY) &&
Number.isFinite(contentMaxX) && Number.isFinite(contentMaxY)) {
const safetyMargin = 16;
minX = Math.min(minX, contentMinX - safetyMargin);
minY = Math.min(minY, contentMinY - safetyMargin);
maxX = Math.max(maxX, contentMaxX + safetyMargin);
maxY = Math.max(maxY, contentMaxY + safetyMargin);
}
}
const width = Math.max(1, maxX - minX);
const height = Math.max(1, maxY - minY);
svg.setAttribute('viewBox', `${minX} ${minY} ${width} ${height}`);
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
return { width, height };
}
const measured = svg.getBoundingClientRect();
let minX = 0;
let minY = 0;
let maxX = Math.max(1, measured.width || 800);
let maxY = Math.max(1, measured.height || 450);
try {
let contentBounds;
try {
contentBounds = svg.getBBox({ fill: true, stroke: true, markers: true });
} catch {
contentBounds = svg.getBBox();
}
if (contentBounds && Number.isFinite(contentBounds.x) && Number.isFinite(contentBounds.y) &&
Number.isFinite(contentBounds.width) && contentBounds.width > 0 &&
Number.isFinite(contentBounds.height) && contentBounds.height > 0) {
const safetyMargin = 16;
const contentMinX = contentBounds.x - safetyMargin;
const contentMinY = contentBounds.y - safetyMargin;
const contentMaxX = contentBounds.x + contentBounds.width + safetyMargin;
const contentMaxY = contentBounds.y + contentBounds.height + safetyMargin;
minX = contentMinX;
minY = contentMinY;
maxX = contentMaxX;
maxY = contentMaxY;
}
} catch {
// Some SVG implementations do not expose a measurable bounding box.
}
const width = Math.max(1, maxX - minX);
const height = Math.max(1, maxY - minY);
svg.setAttribute('viewBox', `${minX} ${minY} ${width} ${height}`);
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
return { width, height };
},
renderMermaidItem: async function (mermaid, item) {
if (item.isRendering) return;
item.isRendering = true;
item.wrapper.classList.remove('is-pending', 'is-rendered', 'is-error');
item.wrapper.classList.add('is-rendering');
item.wrapper.setAttribute('aria-busy', 'true');
this.setMermaidRenderControls(item, false);
const diagram = document.createElement('div');
diagram.className = 'mermaid';
diagram.textContent = item.source;
item.diagram = diagram;
item.canvas.replaceChildren(diagram);
item.viewport.replaceChildren(item.canvas);
let renderStage = 'parse';
try {
if (document.fonts?.ready) await document.fonts.ready;
const parseResult = await mermaid.parse(item.source, { suppressErrors: true });
if (parseResult === false) throw new SyntaxError('Mermaid rejected the diagram source.');
renderStage = 'render';
await mermaid.run({
nodes: [diagram],
suppressErrors: true
});
if (document.fonts?.ready) await document.fonts.ready;
await new Promise(resolve => requestAnimationFrame(resolve));
const svg = diagram.querySelector('svg');
if (!svg) throw new Error('Mermaid did not produce an SVG.');
const dimensions = this.measureMermaidSvg(svg);
item.svg = svg;
item.naturalWidth = dimensions.width;
item.naturalHeight = dimensions.height;
item.scale = 1;
item.userAdjusted = false;
item.isDark = document.documentElement.classList.contains('dark');
svg.setAttribute('role', 'img');
svg.setAttribute('aria-label', 'Mermaid 图表');
svg.style.maxWidth = 'none';
svg.style.flex = '0 0 auto';
item.wrapper.classList.remove('is-rendering');
item.wrapper.classList.add('is-rendered');
item.wrapper.removeAttribute('aria-busy');
this.setMermaidRenderControls(item, true);
requestAnimationFrame(() => this.fitMermaid(item));
if (!item.resizeObserver && window.ResizeObserver) {
let lastWidth = item.viewport.clientWidth;
item.resizeObserver = new ResizeObserver(() => {
const width = item.viewport.clientWidth;
if (Math.abs(width - lastWidth) < 8) return;
lastWidth = width;
if (!item.userAdjusted) this.fitMermaid(item);
});
item.resizeObserver.observe(item.viewport);
}
} catch (error) {
this.showMermaidSource(
item,
renderStage === 'parse'
? 'Mermaid 语法有误,已显示原始内容'
: 'Mermaid 图表渲染失败,已显示原始内容');
console.warn('Mermaid render failed:', error);
} finally {
item.isRendering = false;
}
},
setMermaidRenderControls: function (item, enabled) {
[item.buttons.zoomOut, item.buttons.zoomIn, item.buttons.fit, item.buttons.svg, item.buttons.png]
.forEach(button => { button.disabled = !enabled; });
},
setMermaidScale: function (item, requestedScale, userAdjusted = true) {
if (!item.svg || !item.naturalWidth || !item.naturalHeight) return;
const scale = Math.min(4, Math.max(0.1, requestedScale));
const oldScale = item.scale || 1;
const centerX = item.viewport.scrollLeft + item.viewport.clientWidth / 2;
const centerY = item.viewport.scrollTop + item.viewport.clientHeight / 2;
item.scale = scale;
item.userAdjusted = userAdjusted;
const renderedWidth = Math.max(1, item.naturalWidth * scale);
const renderedHeight = Math.max(1, item.naturalHeight * scale);
item.svg.style.width = `${renderedWidth}px`;
item.svg.style.height = `${renderedHeight}px`;
item.diagram.style.width = `${renderedWidth}px`;
item.diagram.style.height = `${renderedHeight}px`;
item.zoomValue.value = `${Math.round(scale * 100)}%`;
item.zoomValue.textContent = item.zoomValue.value;
item.buttons.zoomOut.disabled = scale <= 0.101;
item.buttons.zoomIn.disabled = scale >= 3.999;
if (userAdjusted) {
requestAnimationFrame(() => {
const ratio = scale / oldScale;
item.viewport.scrollLeft = centerX * ratio - item.viewport.clientWidth / 2;
item.viewport.scrollTop = centerY * ratio - item.viewport.clientHeight / 2;
});
}
},
fitMermaid: function (item) {
if (!item.svg || !item.naturalWidth) return;
const canvasStyle = window.getComputedStyle(item.canvas);
const horizontalPadding = (parseFloat(canvasStyle.paddingLeft) || 0) +
(parseFloat(canvasStyle.paddingRight) || 0);
const availableWidth = Math.max(220, item.viewport.clientWidth - horizontalPadding);
const scale = Math.min(1, availableWidth / item.naturalWidth);
this.setMermaidScale(item, scale, false);
item.viewport.scrollLeft = 0;
item.viewport.scrollTop = 0;
},
bindMermaidTools: function (item) {
item.buttons.zoomOut.addEventListener('click', () =>
this.setMermaidScale(item, item.scale / 1.2));
item.buttons.zoomIn.addEventListener('click', () =>
this.setMermaidScale(item, item.scale * 1.2));
item.buttons.fit.addEventListener('click', () => this.fitMermaid(item));
item.buttons.expand.addEventListener('click', () => this.toggleMermaidExpanded(item));
item.buttons.copy.addEventListener('click', async () => {
try {
await this.copyMermaidSource(item.source);
this.flashMermaidButton(item.buttons.copy, 'bi-check-lg', '已复制');
} catch {
this.flashMermaidButton(item.buttons.copy, 'bi-exclamation-lg', '复制失败', true);
}
});
item.buttons.svg.addEventListener('click', () => this.downloadMermaidSvg(item));
item.buttons.png.addEventListener('click', async () => {
item.buttons.png.disabled = true;
try {
await this.downloadMermaidPng(item);
this.flashMermaidButton(item.buttons.png, 'bi-check-lg', 'PNG 已导出');
} catch (error) {
this.flashMermaidButton(item.buttons.png, 'bi-exclamation-lg', 'PNG 导出失败', true);
console.warn('Mermaid PNG export failed:', error);
} finally {
item.buttons.png.disabled = false;
}
});
item.viewport.addEventListener('dblclick', event => {
if (!event.target.closest('a')) this.fitMermaid(item);
});
item.viewport.addEventListener('keydown', event => {
if (event.key === '+' || event.key === '=') {
event.preventDefault();
this.setMermaidScale(item, item.scale * 1.2);
} else if (event.key === '-') {
event.preventDefault();
this.setMermaidScale(item, item.scale / 1.2);
} else if (event.key === '0') {
event.preventDefault();
this.fitMermaid(item);
}
});
let dragState = null;
item.viewport.addEventListener('pointerdown', event => {
if (event.pointerType !== 'mouse' || event.button !== 0 || event.target.closest('a')) return;
if (item.viewport.scrollWidth <= item.viewport.clientWidth &&
item.viewport.scrollHeight <= item.viewport.clientHeight) return;
dragState = {
x: event.clientX,
y: event.clientY,
left: item.viewport.scrollLeft,
top: item.viewport.scrollTop
};
item.viewport.setPointerCapture(event.pointerId);
item.viewport.classList.add('is-panning');
event.preventDefault();
});
item.viewport.addEventListener('pointermove', event => {
if (!dragState) return;
item.viewport.scrollLeft = dragState.left - (event.clientX - dragState.x);
item.viewport.scrollTop = dragState.top - (event.clientY - dragState.y);
});
const endPan = () => {
dragState = null;
item.viewport.classList.remove('is-panning');
};
item.viewport.addEventListener('pointerup', endPan);
item.viewport.addEventListener('pointercancel', endPan);
},
bindMermaidGlobalEvents: function () {
if (this.mermaidGlobalEventsBound) return;
this.mermaidGlobalEventsBound = true;
document.addEventListener('fullscreenchange', () => {
this.mermaidItems?.forEach(item => {
if (!item.wrapper.isConnected) return;
const isFullscreen = document.fullscreenElement === item.wrapper;
if (isFullscreen !== item.isNativeFullscreen) {
item.isNativeFullscreen = isFullscreen;
requestAnimationFrame(() => this.fitMermaid(item));
}
this.updateMermaidExpandButton(item);
});
});
document.addEventListener('keydown', event => {
if (event.key !== 'Escape') return;
const expanded = document.querySelector('.mermaid-wrapper.is-expanded');
if (!expanded) return;
expanded.classList.remove('is-expanded');
document.body.classList.remove('mermaid-expanded');
const item = [...(this.mermaidItems || [])].find(value => value.wrapper === expanded);
if (item) {
this.updateMermaidExpandButton(item);
requestAnimationFrame(() => this.fitMermaid(item));
}
});
const refreshVisibleContent = () =>
requestAnimationFrame(() => this.refreshMermaid());
document.addEventListener('shown.bs.tab', refreshVisibleContent);
document.addEventListener('shown.bs.collapse', refreshVisibleContent);
document.addEventListener('shown.bs.modal', refreshVisibleContent);
document.addEventListener('shown.bs.offcanvas', refreshVisibleContent);
document.addEventListener('toggle', refreshVisibleContent, true);
this.mermaidTheme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
this.mermaidThemeObserver = new MutationObserver(() => {
const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
if (theme === this.mermaidTheme) return;
this.mermaidTheme = theme;
clearTimeout(this.mermaidThemeTimer);
this.mermaidThemeTimer = setTimeout(() => this.rerenderMermaidForTheme(), 80);
});
this.mermaidThemeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
},
rerenderMermaidForTheme: async function () {
try {
this.pruneMermaidItems();
const mermaid = await this.loadMermaid();
mermaid.initialize(this.getMermaidConfig());
for (const item of this.mermaidItems || []) {
if (item.wrapper.isConnected) await this.renderMermaidWhenVisible(mermaid, item);
}
} catch (error) {
console.warn('Mermaid theme refresh failed:', error);
}
},
loadMermaid: function () {
if (!this.mermaidPromise) {
this.mermaidPromise = new Promise((resolve, reject) => {
const initialize = () => {
if (!window.mermaid) {
reject(new Error('The local Mermaid library did not initialize.'));
return;
}
window.mermaid.initialize(this.getMermaidConfig());
resolve(window.mermaid);
};
if (window.mermaid) {
initialize();
return;
}
const existingScript = document.querySelector('script[data-mermaid-library]');
if (existingScript) {
existingScript.addEventListener('load', initialize, { once: true });
existingScript.addEventListener(
'error',
() => reject(new Error('The local Mermaid library could not be loaded.')),
{ once: true }
);
return;
}
const script = document.createElement('script');
script.src = 'https://xfe-resources.invalid/Documents/Mermaid.min.js';
script.dataset.mermaidLibrary = 'true';
script.addEventListener('load', initialize, { once: true });
script.addEventListener(
'error',
() => reject(new Error('The local Mermaid library could not be loaded.')),
{ once: true }
);
document.head.appendChild(script);
});
}
return this.mermaidPromise;
},
toggleMermaidExpanded: async function (item) {
if (document.fullscreenElement === item.wrapper) {
await document.exitFullscreen();
return;
}
if (item.wrapper.classList.contains('is-expanded')) {
item.wrapper.classList.remove('is-expanded');
document.body.classList.remove('mermaid-expanded');
this.updateMermaidExpandButton(item);
requestAnimationFrame(() => this.fitMermaid(item));
return;
}
if (item.wrapper.requestFullscreen) {
try {
await item.wrapper.requestFullscreen();
return;
} catch {
// Fall back to an in-page overlay when native fullscreen is unavailable.
}
}
document.querySelectorAll('.mermaid-wrapper.is-expanded').forEach(wrapper =>
wrapper.classList.remove('is-expanded'));
item.wrapper.classList.add('is-expanded');
document.body.classList.add('mermaid-expanded');
this.updateMermaidExpandButton(item);
requestAnimationFrame(() => this.fitMermaid(item));
},
updateMermaidExpandButton: function (item) {
const expanded = document.fullscreenElement === item.wrapper ||
item.wrapper.classList.contains('is-expanded');
const icon = item.buttons.expand.querySelector('i');
if (icon) icon.className = `bi ${expanded ? 'bi-fullscreen-exit' : 'bi-arrows-fullscreen'}`;
item.buttons.expand.classList.toggle('is-active', expanded);
item.buttons.expand.title = expanded ? '退出全屏' : '全屏查看';
item.buttons.expand.setAttribute('aria-label', item.buttons.expand.title);
item.buttons.expand.setAttribute('aria-pressed', String(expanded));
},
copyMermaidSource: async function (source) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(source);
return;
}
const textarea = document.createElement('textarea');
textarea.value = source;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
textarea.remove();
},
flashMermaidButton: function (button, iconClass, message, isError = false) {
const icon = button.querySelector('i');
const originalIcon = icon?.className;
const originalTitle = button.title;
if (icon) icon.className = `bi ${iconClass}`;
button.classList.toggle('is-error', isError);
button.classList.toggle('is-success', !isError);
button.title = message;
button.setAttribute('aria-label', message);
setTimeout(() => {
if (icon && originalIcon) icon.className = originalIcon;
button.classList.remove('is-error', 'is-success');
button.title = originalTitle;
button.setAttribute('aria-label', originalTitle);
}, 1600);
},
serializeMermaidSvg: function (item, rasterSafe = false) {
if (!item.svg) throw new Error('No rendered Mermaid SVG is available.');
const dimensions = this.measureMermaidSvg(item.svg);
item.naturalWidth = dimensions.width;
item.naturalHeight = dimensions.height;
const clone = item.svg.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
clone.setAttribute('width', String(Math.ceil(item.naturalWidth)));
clone.setAttribute('height', String(Math.ceil(item.naturalHeight)));
clone.style.width = '';
clone.style.height = '';
clone.style.maxWidth = '';
// Chromium marks canvases as tainted when an SVG contains foreignObject labels.
// Replace those HTML labels with plain SVG text only for raster export.
if (rasterSafe) {
clone.querySelectorAll('foreignObject').forEach(foreignObject => {
const namespace = 'http://www.w3.org/2000/svg';
const text = document.createElementNS(namespace, 'text');
const x = Number.parseFloat(foreignObject.getAttribute('x') || '0');
const y = Number.parseFloat(foreignObject.getAttribute('y') || '0');
const width = Number.parseFloat(foreignObject.getAttribute('width') || '0');
const height = Number.parseFloat(foreignObject.getAttribute('height') || '0');
text.setAttribute('x', String(x + width / 2));
text.setAttribute('y', String(y + height / 2));
text.setAttribute('text-anchor', 'middle');
text.setAttribute('dominant-baseline', 'central');
text.setAttribute('font-family', 'Segoe UI, Microsoft YaHei, sans-serif');
text.setAttribute('font-size', '14');
text.setAttribute('font-weight', '600');
text.setAttribute('fill', item.isDark ? '#f4f0ff' : '#2d2645');
text.textContent = (foreignObject.textContent || '').replace(/\s+/g, ' ').trim();
foreignObject.replaceWith(text);
});
}
return new XMLSerializer().serializeToString(clone);
},
downloadBlob: function (blob, fileName) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
},
downloadMermaidSvg: function (item) {
try {
const source = this.serializeMermaidSvg(item);
this.downloadBlob(
new Blob([source], { type: 'image/svg+xml;charset=utf-8' }),
'mermaid-diagram.svg'
);
this.flashMermaidButton(item.buttons.svg, 'bi-check-lg', 'SVG 已导出');
} catch (error) {
this.flashMermaidButton(item.buttons.svg, 'bi-exclamation-lg', 'SVG 导出失败', true);
console.warn('Mermaid SVG export failed:', error);
}
},
downloadMermaidPng: async function (item) {
const svgSource = this.serializeMermaidSvg(item, true);
const svgUrl = URL.createObjectURL(new Blob([svgSource], { type: 'image/svg+xml;charset=utf-8' }));
try {
const image = new Image();
image.src = svgUrl;
await image.decode();
const maxDimension = 8192;
const density = Math.min(
2,
maxDimension / item.naturalWidth,
maxDimension / item.naturalHeight
);
const width = Math.max(1, Math.round(item.naturalWidth * density));
const height = Math.max(1, Math.round(item.naturalHeight * density));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) throw new Error('Canvas is unavailable.');
context.fillStyle = item.isDark ? '#15131f' : '#fbfaff';
context.fillRect(0, 0, width, height);
context.drawImage(image, 0, 0, width, height);
const blob = await new Promise((resolve, reject) => {
canvas.toBlob(value => value ? resolve(value) : reject(new Error('PNG encoding failed.')), 'image/png');
});
this.downloadBlob(blob, 'mermaid-diagram.png');
} finally {
URL.revokeObjectURL(svgUrl);
}
},
showMermaidSource: function (item, message) {
item.wrapper.classList.remove('is-pending', 'is-rendering', 'is-rendered');
item.wrapper.classList.add('is-error');
item.wrapper.removeAttribute('aria-busy');
item.pendingMermaid = null;
item.visibilityObserver?.disconnect();
item.visibilityObserver = null;
item.svg = null;
item.naturalWidth = 0;
item.naturalHeight = 0;
this.setMermaidRenderControls(item, false);
const title = document.createElement('div');
title.className = 'mermaid-error-title';
title.textContent = message;
const pre = document.createElement('pre');
pre.className = 'mermaid-source';
const code = document.createElement('code');
code.textContent = item.source;
pre.appendChild(code);
item.viewport.replaceChildren(title, pre);
},
// --- Image Lightbox ---
initImageLightbox: function (container) {
const images = container.querySelectorAll('.markdown-body img');
images.forEach(img => {
// Linked images (for example status badges) should keep normal link behavior.
if (img.closest('a[href]')) {
img.style.cursor = '';
return;
}
img.style.cursor = 'zoom-in';
// Prevent double binding
if (img.dataset.lightboxBound) return;
img.dataset.lightboxBound = "true";
img.addEventListener('click', function () {
DocUtils.openImageModal(this.src);
});
});
},
openImageModal: function (src) {
const modalEl = document.getElementById('imageModal');
if (!modalEl) return;
const modalImage = document.getElementById('modalImage');
if (modalImage) modalImage.src = src;
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show();
},
// --- Code Features (Highlight, Border, Label, Copy) ---
applyCodeFeatures: function (container) {
// 1. Syntax Highlighting
if (typeof hljs !== 'undefined') {
if (container === document) {
// Main page load
hljs.highlightAll();
} else {
// Dynamic content
container.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
});
}
}
// 2. Shared toolbar, line numbers and code viewport.
window.XFECodeBlocks?.enhance(container);
},
// --- Math Rendering (Katex) ---
initMathRendering: function (container) {
if (typeof renderMathInElement === 'function') {
// Note: renderMathInElement works on the element itself, not just querySelectorAll
// If container is document, pass document.body
const target = container === document ? document.body : container;
renderMathInElement(target, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true }
],
throwOnError: false
});
}
},
// --- Log Links ---
initLogLinks: function () {
if (document.body.dataset.logLinkBound) return;
document.body.dataset.logLinkBound = "true";
document.addEventListener('click', function (e) {
if (e.target.classList.contains('hljs-log-link')) {
if (e.ctrlKey) {
const url = e.target.innerText;
if (url) {
try {
const validUrl = new URL(url);
window.open('/Redirect?url=' + encodeURIComponent(validUrl.toString()), '_blank');
} catch (err) {
console.error('Invalid URL:', url);
}
}
}
}
});
}
};
// Auto-initialize on DOMContentLoaded
document.addEventListener('DOMContentLoaded', () => {
// Only run if not disabled (allow pages to opt-out or manual control)
if (!document.body.dataset.manualDocInit) {
DocUtils.init();
}
});