XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFESystem

【Rust】XFE系统,基于Rust和汇编

公开
关注 0 Fork 0 Star 0
UTF-8
/**
 * 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 = '/js/docs/mermaid.min.js?v=11.16.0';
                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();
    }
});