GitHub Copilot Live SVG Drawer

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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);

})();