// ==UserScript== // @name Iconfont 复制透明 PNG // @namespace https://www.iconfont.cn/ // @version 3.0.0 // @description 在图标详情中按当前尺寸复制透明 PNG,并将按钮放到“PNG 下载”后面 // @author Claw // @match https://www.iconfont.cn/* // @grant GM_addStyle // @run-at document-idle // ==/UserScript== (function () { 'use strict'; const BUTTON_CLASS = 'tm-copy-transparent-png'; const BUTTON_ATTR = 'data-tm-copy-transparent-png'; const COPYING_CLASS = 'tm-copy-transparent-png-copying'; const TOAST_CLASS = 'tm-copy-transparent-png-toast'; const MAX_SIZE = 8192; GM_addStyle(` /* * 按钮主体样式从“PNG 下载”按钮继承。 * 这里只添加交互状态,不覆盖页面原有尺寸、圆角和背景。 */ .${BUTTON_CLASS} { cursor: pointer !important; } .${BUTTON_CLASS}.${COPYING_CLASS} { cursor: wait !important; opacity: .65 !important; pointer-events: none !important; } .${TOAST_CLASS} { position: fixed !important; top: 72px !important; left: 50% !important; z-index: 2147483647 !important; box-sizing: border-box !important; max-width: 80vw !important; padding: 10px 18px !important; border-radius: 5px !important; transform: translateX(-50%) !important; color: #fff !important; background: rgba(0, 0, 0, .82) !important; box-shadow: 0 3px 12px rgba(0, 0, 0, .18) !important; font-size: 14px !important; line-height: 20px !important; text-align: center !important; white-space: nowrap !important; pointer-events: none !important; } .${TOAST_CLASS}.error { background: rgba(207, 19, 34, .92) !important; } `); /** * 判断元素是否真实可见。 */ function isVisible(element) { if (!(element instanceof Element)) { return false; } const style = getComputedStyle(element); const rect = element.getBoundingClientRect(); return ( style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) !== 0 && rect.width > 0 && rect.height > 0 ); } /** * 标准化元素文字,用于匹配页面按钮。 */ function normalizeText(element) { return String(element?.textContent || '') .replace(/\s+/g, '') .trim(); } /** * 显示操作结果。 */ function showToast(message, error = false) { document.querySelector(`.${TOAST_CLASS}`)?.remove(); const toast = document.createElement('div'); toast.className = TOAST_CLASS + (error ? ' error' : ''); toast.textContent = message; document.body.appendChild(toast); setTimeout(() => { toast.remove(); }, error ? 3500 : 2200); } /** * 找到当前可见的图标详情区域。 * * Iconfont 的详情是动态加载的,并且类名可能变化, * 因此通过“尺寸输入框 + SVG + PNG 下载按钮”综合判断。 */ function findVisibleDetailPanel() { const pngButtons = [ ...document.querySelectorAll( 'button, a, [role="button"], li, div, span' ) ].filter(element => { return ( isVisible(element) && normalizeText(element) === 'PNG下载' ); }); /* * 从“PNG 下载”向上查找同时包含预览 SVG 和尺寸框的容器。 */ for (const pngButton of pngButtons) { let current = pngButton.parentElement; while ( current && current !== document.body && current !== document.documentElement ) { if (!isVisible(current)) { current = current.parentElement; continue; } const hasSvg = Boolean( current.querySelector('svg') ); const hasSizeInput = Boolean( current.querySelector( 'input.size-input, ' + '#J_size_pick_wrap input, ' + 'input[class*="size"]' ) ); const text = normalizeText(current); const hasDetailActions = text.includes('SVG下载') && text.includes('PNG下载'); if ( hasSvg && hasSizeInput && hasDetailActions ) { return current; } current = current.parentElement; } } /* * 备用方案:先从可见尺寸输入框向上查找。 */ const sizeInputs = [ ...document.querySelectorAll( 'input.size-input, ' + '#J_size_pick_wrap input, ' + 'input[class*="size"]' ) ].filter(isVisible); for (const input of sizeInputs) { let current = input.parentElement; while ( current && current !== document.body && current !== document.documentElement ) { const text = normalizeText(current); if ( isVisible(current) && current.querySelector('svg') && text.includes('PNG下载') ) { return current; } current = current.parentElement; } } return null; } /** * 精确寻找详情区域中的“PNG 下载”实际按钮。 */ function findPngDownloadButton(panel) { if (!panel) { return null; } const matches = [ ...panel.querySelectorAll( 'button, a, [role="button"], li, div, span' ) ].filter(element => { return ( isVisible(element) && normalizeText(element) === 'PNG下载' && !element.hasAttribute(BUTTON_ATTR) && !element.closest(`[${BUTTON_ATTR}]`) ); }); if (!matches.length) { return null; } /* * 父容器和内部按钮可能都得到“PNG 下载”文字。 * 通常面积最小的节点才是真正的按钮。 */ matches.sort((a, b) => { const aRect = a.getBoundingClientRect(); const bRect = b.getBoundingClientRect(); const aArea = aRect.width * aRect.height; const bArea = bRect.width * bRect.height; if (aArea !== bArea) { return aArea - bArea; } /* * 面积相同时,优先 button、a 和 role=button。 */ function priority(element) { if (element.tagName === 'BUTTON') return 0; if (element.tagName === 'A') return 1; if (element.getAttribute('role') === 'button') { return 2; } return 3; } return priority(a) - priority(b); }); return matches[0]; } /** * 读取动态详情区域当前设置的尺寸。 */ function getPanelSize(panel) { const inputs = [ ...panel.querySelectorAll( 'input.size-input, ' + '#J_size_pick_wrap input, ' + 'input[class*="size"]' ) ]; const input = inputs.find(isVisible) || inputs.find(element => { return String(element.value || '').trim(); }); if (!input) { throw new Error('没有找到详情中的尺寸输入框'); } const rawValue = String(input.value || '').trim(); const size = Number.parseInt(rawValue, 10); if (!Number.isFinite(size) || size <= 0) { throw new Error( `尺寸无效:${rawValue || '空'}` ); } if (size > MAX_SIZE) { throw new Error( `尺寸不能超过 ${MAX_SIZE}px` ); } return size; } /** * 从详情区域找到真正的图标预览 SVG。 * * 排除按钮图标、问号和其他装饰 SVG, * 优先选取可见面积最大的 SVG。 */ function getPreviewSvg(panel) { const svgs = [ ...panel.querySelectorAll('svg') ].filter(svg => { if (!isVisible(svg)) { return false; } if (svg.closest(`[${BUTTON_ATTR}]`)) { return false; } const rect = svg.getBoundingClientRect(); if (rect.width < 24 || rect.height < 24) { return false; } return Boolean( svg.querySelector( 'path, circle, rect, polygon, ' + 'polyline, ellipse, line, use' ) ); }); svgs.sort((a, b) => { const aRect = a.getBoundingClientRect(); const bRect = b.getBoundingClientRect(); const aArea = aRect.width * aRect.height; const bArea = bRect.width * bRect.height; return bArea - aArea; }); if (!svgs.length) { throw new Error('没有找到详情中的图标 SVG'); } return svgs[0]; } /** * 将网页 CSS 的最终计算样式写入克隆后的 SVG。 * * SVG 脱离网页后,CSS 类和 currentColor 可能失效; * 写入计算值可以保留单色和多色图标。 */ function copyComputedSvgStyles(sourceSvg, clonedSvg) { const sourceNodes = [ sourceSvg, ...sourceSvg.querySelectorAll('*') ]; const clonedNodes = [ clonedSvg, ...clonedSvg.querySelectorAll('*') ]; clonedNodes.forEach((clonedNode, index) => { const sourceNode = sourceNodes[index]; if (!sourceNode) { return; } const style = getComputedStyle(sourceNode); if ( !clonedNode.hasAttribute('fill') && style.fill && style.fill !== 'none' ) { clonedNode.setAttribute( 'fill', style.fill ); } if ( !clonedNode.hasAttribute('stroke') && style.stroke && style.stroke !== 'none' ) { clonedNode.setAttribute( 'stroke', style.stroke ); } if ( !clonedNode.hasAttribute('stroke-width') && style.strokeWidth && style.strokeWidth !== '0px' ) { clonedNode.setAttribute( 'stroke-width', style.strokeWidth ); } if ( !clonedNode.hasAttribute('stroke-linecap') && style.strokeLinecap ) { clonedNode.setAttribute( 'stroke-linecap', style.strokeLinecap ); } if ( !clonedNode.hasAttribute('stroke-linejoin') && style.strokeLinejoin ) { clonedNode.setAttribute( 'stroke-linejoin', style.strokeLinejoin ); } if ( !clonedNode.hasAttribute('opacity') && style.opacity && style.opacity !== '1' ) { clonedNode.setAttribute( 'opacity', style.opacity ); } if ( !clonedNode.hasAttribute('fill-opacity') && style.fillOpacity && style.fillOpacity !== '1' ) { clonedNode.setAttribute( 'fill-opacity', style.fillOpacity ); } if ( !clonedNode.hasAttribute('stroke-opacity') && style.strokeOpacity && style.strokeOpacity !== '1' ) { clonedNode.setAttribute( 'stroke-opacity', style.strokeOpacity ); } }); } /** * 准备用于离屏渲染的 SVG。 */ function prepareSvg(sourceSvg, size) { const svg = sourceSvg.cloneNode(true); svg.setAttribute( 'xmlns', 'http://www.w3.org/2000/svg' ); svg.setAttribute( 'xmlns:xlink', 'http://www.w3.org/1999/xlink' ); if (!svg.getAttribute('viewBox')) { const sourceRect = sourceSvg.getBoundingClientRect(); const width = Number.parseFloat( sourceSvg.getAttribute('width') ) || sourceRect.width || 1024; const height = Number.parseFloat( sourceSvg.getAttribute('height') ) || sourceRect.height || 1024; svg.setAttribute( 'viewBox', `0 0 ${width} ${height}` ); } /* * 输出像素尺寸严格使用详情中设置的数值。 */ svg.setAttribute('width', String(size)); svg.setAttribute('height', String(size)); svg.style.width = `${size}px`; svg.style.height = `${size}px`; svg.style.display = 'block'; svg.style.margin = '0'; svg.style.padding = '0'; svg.style.background = 'transparent'; svg.style.overflow = 'visible'; const sourceColor = getComputedStyle(sourceSvg).color; if (sourceColor) { svg.style.color = sourceColor; } copyComputedSvgStyles(sourceSvg, svg); return svg; } /** * 把 SVG 渲染为透明 PNG Blob。 */ function svgToTransparentPng(sourceSvg, size) { return new Promise((resolve, reject) => { const preparedSvg = prepareSvg(sourceSvg, size); const serialized = new XMLSerializer().serializeToString( preparedSvg ); const svgBlob = new Blob( [serialized], { type: 'image/svg+xml;charset=utf-8' } ); const objectUrl = URL.createObjectURL(svgBlob); const image = new Image(); image.onload = () => { try { const canvas = document.createElement('canvas'); /* * 不乘 devicePixelRatio: * 用户设置 200,结果就是 200×200 物理像素。 */ canvas.width = size; canvas.height = size; const context = canvas.getContext( '2d', { alpha: true } ); if (!context) { throw new Error( '无法创建 Canvas 绘图上下文' ); } /* * Canvas 默认透明;明确清空一次。 * 这里不能填白色或其他背景。 */ context.clearRect( 0, 0, size, size ); context.drawImage( image, 0, 0, size, size ); URL.revokeObjectURL(objectUrl); canvas.toBlob(blob => { if (!blob) { reject( new Error('PNG 生成失败') ); return; } resolve(blob); }, 'image/png'); } catch (error) { URL.revokeObjectURL(objectUrl); reject(error); } }; image.onerror = () => { URL.revokeObjectURL(objectUrl); reject( new Error('SVG 图像渲染失败') ); }; image.src = objectUrl; }); } /** * 生成 PNG 并写入系统剪贴板。 */ async function copyPngFromPanel(panel) { if (!navigator.clipboard?.write) { throw new Error( '浏览器不支持图片剪贴板,请使用最新版 Chrome' ); } if (typeof ClipboardItem === 'undefined') { throw new Error( '当前浏览器不支持 ClipboardItem' ); } /* * 点击时实时读取,避免缓存上一次动态弹窗的数据。 */ const size = getPanelSize(panel); const svg = getPreviewSvg(panel); const pngBlob = await svgToTransparentPng(svg, size); await navigator.clipboard.write([ new ClipboardItem({ 'image/png': pngBlob }) ]); return size; } /** * 清理克隆按钮中可能导致页面执行 PNG 下载的属性。 */ function cleanClonedButton(button) { button.removeAttribute('id'); button.removeAttribute('href'); button.removeAttribute('target'); button.removeAttribute('download'); button.removeAttribute('onclick'); button.removeAttribute('mx-click'); button.removeAttribute('mx-mousedown'); button.removeAttribute('mx-pointerdown'); button.removeAttribute('data-spm-click'); button.removeAttribute('disabled'); button.removeAttribute('aria-disabled'); const allElements = [ button, ...button.querySelectorAll('*') ]; allElements.forEach(element => { element.removeAttribute('id'); [...element.attributes].forEach(attribute => { const name = attribute.name.toLowerCase(); if ( name.startsWith('on') || name === 'mx-click' || name === 'mx-mousedown' || name === 'mx-pointerdown' || name === 'data-spm-click' ) { element.removeAttribute( attribute.name ); } }); }); } /** * 替换按钮文字,同时尽量保留原按钮的内部结构和图标。 */ function setButtonText(button, text) { const textNodes = []; const walker = document.createTreeWalker( button, NodeFilter.SHOW_TEXT ); let node; while ((node = walker.nextNode())) { if (node.nodeValue.trim()) { textNodes.push(node); } } if (!textNodes.length) { button.textContent = text; return; } textNodes[0].nodeValue = text; /* * 删除原按钮中其余文字,避免残留“PNG 下载”。 */ textNodes.slice(1).forEach(textNode => { textNode.nodeValue = ''; }); } /** * 在“PNG 下载”正后方注入复制按钮。 */ function injectButton(panel) { if (!panel) { return; } /* * 当前详情中已有按钮时,不重复注入。 */ if (panel.querySelector(`[${BUTTON_ATTR}]`)) { return; } const pngDownloadButton = findPngDownloadButton(panel); /* * 动态弹窗可能只渲染了一部分。 * 如果下载按钮还没出现,等待下一次扫描。 */ if (!pngDownloadButton) { return; } /* * 克隆“PNG 下载”按钮,以继承页面的类名、 * 灰色背景、圆角、字体、尺寸和 hover 样式。 * * cloneNode 不会复制 addEventListener 事件。 */ const button = pngDownloadButton.cloneNode(true); cleanClonedButton(button); button.classList.add(BUTTON_CLASS); button.setAttribute(BUTTON_ATTR, 'true'); button.setAttribute( 'title', '按当前尺寸复制透明 PNG' ); button.setAttribute( 'aria-label', '复制透明 PNG' ); if (button.tagName === 'BUTTON') { button.type = 'button'; } setButtonText(button, '复制透明 PNG'); button.addEventListener( 'click', async event => { /* * 捕获并停止传播,避免 Iconfont 的事件代理 * 把克隆按钮误识别为 PNG 下载按钮。 */ event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); if ( button.classList.contains( COPYING_CLASS ) ) { return; } button.classList.add(COPYING_CLASS); button.setAttribute( 'aria-disabled', 'true' ); setButtonText( button, '正在复制…' ); try { /* * 每次点击重新识别当前动态详情面板。 */ const currentPanel = findVisibleDetailPanel() || panel; const size = await copyPngFromPanel( currentPanel ); setButtonText( button, '复制成功' ); showToast( `已复制透明 PNG:` + `${size} × ${size}px` ); } catch (error) { console.error( '[Iconfont 复制透明 PNG]', error ); setButtonText( button, '复制失败' ); showToast( `复制失败:${ error?.message || String(error) }`, true ); } finally { setTimeout(() => { if (!button.isConnected) { return; } button.classList.remove( COPYING_CLASS ); button.removeAttribute( 'aria-disabled' ); setButtonText( button, '复制透明 PNG' ); }, 1200); } }, true ); /* * 插在“PNG 下载”实际按钮的正后面。 */ pngDownloadButton.after(button); } /** * 扫描当前动态详情。 */ function scanDynamicDetail() { const panel = findVisibleDetailPanel(); if (panel) { injectButton(panel); } } let scanTimer = null; /** * 监听 Iconfont 动态弹窗的创建、切换和重新渲染。 */ const observer = new MutationObserver(() => { clearTimeout(scanTimer); scanTimer = setTimeout(() => { scanDynamicDetail(); }, 80); }); observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: [ 'class', 'style', 'aria-hidden' ] }); /* * 定时检查作为备用机制。 * 某些框架复用节点时不一定触发明显的子节点变化。 */ setInterval(scanDynamicDetail, 800); scanDynamicDetail(); })();