SVG 增量渲染 + 灯箱预览 + 表格圆角 + DM Sans 字体
// ==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);
})();