GitHub Copilot Live SVG Drawer

SVG 增量渲染 + 灯箱预览 + 表格圆角 + DM Sans 字体

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         GitHub Copilot Live SVG Drawer
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  SVG 增量渲染 + 灯箱预览 + 表格圆角 + DM Sans 字体
// @author       hugoblog.com
// @match        https://github.com/copilot/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    // ════════════════════════════════════════════════════════════
    //  PART 1 · 样式注入
    // ════════════════════════════════════════════════════════════
    const styleEl = document.createElement('style');
    styleEl.textContent = `
        @import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;1,9..40,400&display=swap');

        /* ── 字体 ── */
        body, input, textarea, button, select,
        [class*="Message"], [class*="Input"], [class*="Thread"],
        [class*="markdown"], [class*="Markdown"] {
            font-family: 'DM Sans', ui-sans-serif, system-ui, -apple-system, sans-serif !important;
            font-feature-settings: "kern" 1, "liga" 1 !important;
            -webkit-font-smoothing: antialiased !important;
        }
        code, pre, kbd,
        [class*="CodeBlock"] code,
        [class*="CodeBlock"] pre {
            font-family: var(--fontStack-monospace, 'SF Mono', ui-monospace, Consolas, monospace) !important;
        }

        /* ── 表格 wrapper ── */
        .cl-table-wrapper {
            display       : inline-block !important;
            max-width     : 100%         !important;
            overflow-x    : auto         !important;
            clip-path     : inset(0 round 8px) !important;
            border        : 1px solid rgba(255,255,255,0.09) !important;
            box-shadow    : 0 1px 8px rgba(0,0,0,0.28) !important;
            margin        : 0.75em 0     !important;
            vertical-align: top          !important;
        }
        .cl-table-wrapper table {
            border-collapse : collapse !important;
            border-spacing  : 0        !important;
            font-size       : 0.8rem   !important;
            line-height     : 1.55     !important;
            border          : none     !important;
            margin          : 0        !important;
        }
        .cl-table-wrapper th {
            background    : rgba(255,255,255,0.055) !important;
            color         : rgba(255,255,255,0.52)  !important;
            font-weight   : 500                     !important;
            font-size     : 0.72rem                 !important;
            letter-spacing: 0.045em                 !important;
            text-transform: uppercase               !important;
            padding       : 6px 14px                !important;
            border-bottom : 1px solid rgba(255,255,255,0.09) !important;
            border-right  : 1px solid rgba(255,255,255,0.05) !important;
            white-space   : nowrap                  !important;
        }
        .cl-table-wrapper th:last-child { border-right: none !important; }
        .cl-table-wrapper td {
            padding        : 6px 14px               !important;
            color          : rgba(255,255,255,0.78) !important;
            border-bottom  : 1px solid rgba(255,255,255,0.05) !important;
            border-right   : 1px solid rgba(255,255,255,0.04) !important;
            font-size      : 0.8rem                 !important;
            vertical-align : top                    !important;
        }
        .cl-table-wrapper td:last-child    { border-right:  none !important; }
        .cl-table-wrapper tr:last-child td { border-bottom: none !important; }
        .cl-table-wrapper tbody tr:hover td {
            background: rgba(255,255,255,0.03) !important;
            transition: background 0.12s       !important;
        }

        /* ── SVG 预览区 ── */
        .live-svg-preview { cursor: zoom-in; }

        /* ── 灯箱遮罩 ── */
        #svg-lightbox-overlay {
            position       : fixed;
            inset          : 0;
            z-index        : 99999;
            background     : rgba(0,0,0,0.85);
            backdrop-filter: blur(6px);
            display        : flex;
            align-items    : center;
            justify-content: center;
            opacity        : 0;
            transition     : opacity 0.2s ease;
            pointer-events : none;
        }
        #svg-lightbox-overlay.visible {
            opacity       : 1;
            pointer-events: all;
        }
        #svg-lightbox-box {
            position: relative;
            display : flex;
            align-items    : center;
            justify-content: center;
            padding : 24px;
        }
        #svg-lightbox-close {
            position     : absolute;
            top          : 4px;
            right        : 4px;
            width        : 28px;
            height       : 28px;
            border-radius: 50%;
            background   : rgba(50,50,50,0.95);
            border       : 1px solid rgba(255,255,255,0.18);
            color        : rgba(255,255,255,0.85);
            font-size    : 17px;
            line-height  : 26px;
            text-align   : center;
            cursor       : pointer;
            user-select  : none;
            transition   : background 0.15s;
            z-index      : 1;
        }
        #svg-lightbox-close:hover { background: rgba(110,110,110,0.95); }
        #svg-lightbox-hint {
            position   : absolute;
            bottom     : 4px;
            left       : 50%;
            transform  : translateX(-50%);
            font-size  : 11px;
            color      : rgba(255,255,255,0.25);
            white-space: nowrap;
            pointer-events: none;
            font-family: 'DM Sans', sans-serif;
        }
    `;
    document.head.appendChild(styleEl);


    // ════════════════════════════════════════════════════════════
    //  PART 2 · 表格 wrapper
    // ════════════════════════════════════════════════════════════
    function wrapTable(table) {
        if (table.dataset.clWrapped) return;
        if (table.parentElement?.classList.contains('cl-table-wrapper')) {
            table.dataset.clWrapped = 'true';
            return;
        }
        table.dataset.clWrapped = 'true';
        const wrapper = document.createElement('div');
        wrapper.className = 'cl-table-wrapper';
        table.parentNode.insertBefore(wrapper, table);
        wrapper.appendChild(table);
    }

    function wrapAllTables() {
        document.querySelectorAll('table:not([data-cl-wrapped])').forEach(wrapTable);
    }
    wrapAllTables();
    new MutationObserver(() => requestAnimationFrame(wrapAllTables))
        .observe(document.body, { childList: true, subtree: true });


    // ════════════════════════════════════════════════════════════
    //  PART 3 · 灯箱逻辑
    // ════════════════════════════════════════════════════════════
    const SCALE_MIN  = 0.1;
    const SCALE_MAX  = 10;
    const SCALE_STEP = 0.12;
    let lightboxScale = 1;
    let lightboxSvgEl = null;

    const overlay  = document.createElement('div');
    overlay.id = 'svg-lightbox-overlay';
    const box      = document.createElement('div');
    box.id = 'svg-lightbox-box';
    const closeBtn = document.createElement('div');
    closeBtn.id = 'svg-lightbox-close';
    closeBtn.textContent = '×';
    const hint = document.createElement('div');
    hint.id = 'svg-lightbox-hint';
    hint.textContent = '滚轮缩放 · 点击外侧关闭';
    box.appendChild(closeBtn);
    box.appendChild(hint);
    overlay.appendChild(box);
    document.body.appendChild(overlay);

    const serializer = new XMLSerializer();

    function openLightbox(svgSource) {
        // 移除旧节点
        const old = box.querySelector('svg');
        if (old) old.remove();

        // ── 核心修复:序列化 → 重新解析,得到干净的 SVG 元素 ──
        // cloneNode 会保留所有动画内联样式和 transition,导致不可预期的渲染
        // XMLSerializer 序列化后重新 parse,得到一个全新、干净的 DOM 节点
        let svgStr;
        try {
            svgStr = serializer.serializeToString(svgSource);
        } catch (e) {
            return;
        }
        const freshSvg = parseSVG(svgStr);
        if (!freshSvg) return;

        // 确保 viewBox 存在(用于计算宽高)
        if (!freshSvg.getAttribute('viewBox')) {
            const w = svgSource.getAttribute('width');
            const h = svgSource.getAttribute('height');
            if (w && h) freshSvg.setAttribute('viewBox', `0 0 ${w} ${h}`);
        }

        // ── 根据 viewBox 计算精确像素尺寸(不依赖 CSS auto 解析)──
        // CSS width:auto 在 flex 容器里可能解析为 0,必须给明确像素值
        const vb    = freshSvg.getAttribute('viewBox');
        const maxW  = window.innerWidth  * 0.84;
        const maxH  = window.innerHeight * 0.76;
        let dispW   = maxW;
        let dispH   = maxH;

        if (vb) {
            const parts = vb.trim().split(/[\s,]+/);
            const vbW   = parseFloat(parts[2]);
            const vbH   = parseFloat(parts[3]);
            if (vbW > 0 && vbH > 0) {
                const scale = Math.min(maxW / vbW, maxH / vbH);
                dispW = vbW * scale;
                dispH = vbH * scale;
            }
        }

        // 清除原始尺寸属性,用计算好的像素值接管
        freshSvg.removeAttribute('width');
        freshSvg.removeAttribute('height');
        freshSvg.removeAttribute('style');
        freshSvg.style.cssText = `
            display          : block;
            width            : ${dispW}px;
            height           : ${dispH}px;
            border-radius    : 10px;
            box-shadow       : 0 8px 48px rgba(0,0,0,0.7);
            transform-origin : center center;
            transform        : scale(1);
            transition       : transform 0.08s ease-out;
            cursor           : default;
            background       : transparent;
        `;

        lightboxScale = 1;
        lightboxSvgEl = freshSvg;
        box.insertBefore(freshSvg, closeBtn);
        overlay.classList.add('visible');
        document.body.style.overflow = 'hidden';
    }

    function closeLightbox() {
        overlay.classList.remove('visible');
        document.body.style.overflow = '';
        setTimeout(() => {
            const old = box.querySelector('svg');
            if (old) old.remove();
            lightboxSvgEl = null;
        }, 220);
    }

    overlay.addEventListener('click', e => { if (e.target === overlay) closeLightbox(); });
    closeBtn.addEventListener('click', e => { e.stopPropagation(); closeLightbox(); });
    document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLightbox(); });

    overlay.addEventListener('wheel', e => {
        e.preventDefault();
        e.stopPropagation();
        if (!lightboxSvgEl) return;
        const dir = e.deltaY < 0 ? 1 : -1;
        lightboxScale = Math.min(SCALE_MAX, Math.max(SCALE_MIN, lightboxScale + dir * SCALE_STEP));
        lightboxSvgEl.style.transform = `scale(${lightboxScale})`;
    }, { passive: false });


    // ════════════════════════════════════════════════════════════
    //  PART 4 · SVG 零闪烁增量渲染
    // ════════════════════════════════════════════════════════════
    const DEBOUNCE_MS     = 120;
    const PREVIEW_OPACITY = 0.35;
    const xmlParser       = new DOMParser();

    function parseSVG(svgStr) {
        const xmlDoc = xmlParser.parseFromString(svgStr, 'image/svg+xml');
        if (!xmlDoc.querySelector('parsererror')) return xmlDoc.querySelector('svg');
        const htmlDoc = xmlParser.parseFromString(
            `<html><body>${svgStr}</body></html>`, 'text/html'
        );
        return htmlDoc.querySelector('svg');
    }

    function processCodeBlock(block) {
        if (block.dataset.svgProcessed) return;
        const codeEl = block.querySelector('code');
        if (!codeEl) return;

        const tryInit = () => {
            if (block.dataset.svgProcessed) return;
            if (!codeEl.innerText.includes('<svg')) return;
            block.dataset.svgProcessed = 'true';

            const pre = block.querySelector('pre');
            if (pre) pre.style.display = 'none';

            const preview = document.createElement('div');
            preview.className = 'live-svg-preview';
            Object.assign(preview.style, {
                padding       : '16px',
                display       : 'flex',
                justifyContent: 'center',
                background    : 'transparent',
            });
            block.appendChild(preview);

            const state = {
                svgEl         : null,
                renderedCount : 0,
                previewNode   : null,
                debounceTimer : null,
            };

            preview.addEventListener('click', () => {
                if (state.svgEl) openLightbox(state.svgEl);
            });

            const schedule = () => {
                clearTimeout(state.debounceTimer);
                state.debounceTimer = setTimeout(
                    () => incrementalRender(codeEl.innerText, state, preview),
                    DEBOUNCE_MS
                );
            };

            new MutationObserver(schedule)
                .observe(codeEl, { childList: true, characterData: true, subtree: true });
            incrementalRender(codeEl.innerText, state, preview);
        };

        tryInit();

        if (!block.dataset.svgProcessed) {
            const initObs = new MutationObserver(() => {
                if (codeEl.innerText.includes('<svg')) {
                    initObs.disconnect();
                    tryInit();
                }
            });
            initObs.observe(codeEl, { childList: true, characterData: true, subtree: true });
        }
    }

    function incrementalRender(rawText, state, container) {
        const svgStart = rawText.indexOf('<svg');
        if (svgStart === -1) return;
        const fragment   = rawText.substring(svgStart);
        const isComplete = fragment.includes('</svg>');
        const parsed     = parseSVG(isComplete ? fragment : fragment + '</svg>');
        if (!parsed) return;

        if (!state.svgEl) {
            state.svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
            syncAttr(parsed, state.svgEl);
            Object.assign(state.svgEl.style, {
                maxWidth  : '100%',
                height    : 'auto',
                background: 'transparent',
                display   : 'block',
            });
            container.appendChild(state.svgEl);
        } else {
            syncAttr(parsed, state.svgEl);
        }

        const kids        = Array.from(parsed.children);
        const stableCount = isComplete ? kids.length : Math.max(0, kids.length - 1);

        for (let i = state.renderedCount; i < stableCount; i++) {
            const node = document.importNode(kids[i], true);
            node.style.opacity    = '0';
            node.style.transition = 'opacity 150ms ease';
            state.svgEl.appendChild(node);
            void node.getBoundingClientRect();
            node.style.opacity = '1';
        }
        state.renderedCount = stableCount;

        if (state.previewNode) {
            state.svgEl.removeChild(state.previewNode);
            state.previewNode = null;
        }
        if (!isComplete && kids.length > 0) {
            const node = document.importNode(kids[kids.length - 1], true);
            node.style.opacity = String(PREVIEW_OPACITY);
            state.svgEl.appendChild(node);
            state.previewNode = node;
        }
    }

    function syncAttr(src, dst) {
        for (const a of src.attributes) {
            if (dst.getAttribute(a.name) !== a.value) dst.setAttribute(a.name, a.value);
        }
    }

    const globalObs = new MutationObserver(() => {
        document
            .querySelectorAll('figure[class*="CodeBlock-module__container"]:not([data-svg-processed])')
            .forEach(processCodeBlock);
    });
    globalObs.observe(document.body, { childList: true, subtree: true });

    setTimeout(() => {
        document
            .querySelectorAll('figure[class*="CodeBlock-module__container"]:not([data-svg-processed])')
            .forEach(processCodeBlock);
    }, 1000);

})();