Greasy Fork is available in English.
Pick any SVG on a page and copy portable markup with inlined <use> sprites and resolved CSS colors. Toggle with Alt+Shift+S.
// ==UserScript==
// @name SVG Clipper
// @namespace https://gist.github.com/aian-dev
// @version 1.3.0
// @description Pick any SVG on a page and copy portable markup with inlined <use> sprites and resolved CSS colors. Toggle with Alt+Shift+S.
// @license MIT
// @match *://*/*
// @run-at document-idle
// @grant GM_setClipboard
// @grant GM_registerMenuCommand
// @icon https://gist.githubusercontent.com/aian-dev/3ba07d9a0835bede6ad5c1143ea381ea/raw/20-icon.png
// ==/UserScript==
(() => {
'use strict';
const SVG_NS = 'http://www.w3.org/2000/svg';
const XLINK_NS = 'http://www.w3.org/1999/xlink';
const HOVER_CLASS = '__svgclipper_hover';
const ACTIVE_CLASS = '__svgclipper_active';
const TOAST_ID = '__svgclipper_toast';
const HUD_ID = '__svgclipper_hud';
// Elements the picker treats as "the clickable wrapper" around an icon.
const CLICKABLE = 'button, a, [role="button"], [role="menuitem"], [role="tab"], [role="option"], summary, label, input[type="button"], input[type="submit"]';
const style = document.createElement('style');
style.textContent = `
svg.${HOVER_CLASS} {
outline: 2px solid #ff3d71 !important;
outline-offset: 2px !important;
}
.${ACTIVE_CLASS}, .${ACTIVE_CLASS} * { cursor: crosshair !important; }
/* Icon SVGs inside buttons usually have pointer-events:none, which hides
them from hit-testing entirely. While the picker is armed, force them
back on so events resolve to the SVG instead of the button. */
.${ACTIVE_CLASS} svg, .${ACTIVE_CLASS} svg * { pointer-events: auto !important; }
#${TOAST_ID} {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
z-index: 2147483647; pointer-events: none;
display: flex; align-items: center; gap: 10px;
background: #111; color: #fff; padding: 8px 12px; border-radius: 8px;
font: 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
box-shadow: 0 4px 16px rgba(0, 0, 0, .35);
opacity: 0; transition: opacity .15s ease;
}
#${TOAST_ID}.show { opacity: 1; }
#${TOAST_ID} .__svgclipper_chip {
display: none; align-items: center; justify-content: center;
width: 56px; height: 56px; flex: 0 0 auto;
background: rgba(255, 255, 255, .95); color: #111; border-radius: 8px;
}
#${TOAST_ID}.has-preview .__svgclipper_chip { display: flex; }
#${TOAST_ID} .__svgclipper_chip svg {
width: 40px !important; height: 40px !important;
max-width: 40px !important; max-height: 40px !important;
outline: none !important;
}
#${HUD_ID} {
position: fixed; top: 16px; right: 16px;
z-index: 2147483647;
display: flex; align-items: center; gap: 8px;
background: #111; color: #fff; padding: 6px 12px; border-radius: 999px;
font: 12px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
box-shadow: 0 4px 16px rgba(0, 0, 0, .35);
cursor: pointer !important;
user-select: none;
}
#${HUD_ID} * { cursor: pointer !important; }
#${HUD_ID} .__svgclipper_dot {
width: 8px; height: 8px; border-radius: 50%; background: #ff3d71;
flex: 0 0 auto;
}
#${HUD_ID} .__svgclipper_x { opacity: .7; font-size: 14px; }
#${HUD_ID}:hover .__svgclipper_x { opacity: 1; }
`;
(document.head || document.documentElement).appendChild(style);
let active = false;
let hovered = null;
let toastEl = null;
let toastMsgEl = null;
let toastChipEl = null;
let hudEl = null;
let toastTimer = 0;
function isOurUi(node) {
return node instanceof Element && !!node.closest(`#${TOAST_ID}, #${HUD_ID}`);
}
function ensureToast() {
if (!toastEl) {
toastEl = document.createElement('div');
toastEl.id = TOAST_ID;
toastChipEl = document.createElement('div');
toastChipEl.className = '__svgclipper_chip';
toastMsgEl = document.createElement('div');
toastEl.append(toastChipEl, toastMsgEl);
}
if (!toastEl.isConnected) (document.body || document.documentElement).appendChild(toastEl);
}
function toast(msg, previewSvg) {
ensureToast();
toastMsgEl.textContent = msg;
toastChipEl.replaceChildren();
if (previewSvg) {
toastChipEl.appendChild(previewSvg);
toastEl.classList.add('has-preview');
} else {
toastEl.classList.remove('has-preview');
}
toastEl.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove('show'), previewSvg ? 3000 : 2200);
}
// Climb out of the SVG (and through shadow roots) to the outermost <svg>.
function outermostSvg(node) {
let svg = null;
let n = node;
while (n) {
if (n instanceof SVGSVGElement) svg = n;
n = n.parentElement || (n.getRootNode && n.getRootNode() instanceof ShadowRoot ? n.getRootNode().host : null);
}
return svg;
}
// Resolve the SVG a picker event refers to. Handles three cases the naive
// version missed: pointer-events:none icons, clicks landing on a button's
// padding, and icons hidden from composedPath by the wrapper.
function svgFromEvent(e) {
let out = null;
if (e.composedPath) {
for (const n of e.composedPath()) {
if (isOurUi(n)) return null;
if (n instanceof SVGSVGElement) out = n; // path is target→root, last one wins
}
}
if (out) return out;
const target = e.target instanceof Element ? e.target : null;
if (target) {
if (isOurUi(target)) return null;
const direct = target.closest('svg');
if (direct) return outermostSvg(direct);
// Click landed on a button/link (or its padding): find the icon inside
// the nearest clickable ancestor.
let el = target;
for (let depth = 0; el && depth < 8; depth++) {
if (el.matches && el.matches(CLICKABLE)) {
const inner = el.querySelector('svg');
if (inner) return outermostSvg(inner);
break;
}
el = el.parentElement || (el.getRootNode && el.getRootNode() instanceof ShadowRoot ? el.getRootNode().host : null);
}
}
// Last resort: hit-test everything stacked under the pointer.
if (typeof e.clientX === 'number' && document.elementsFromPoint) {
for (const n of document.elementsFromPoint(e.clientX, e.clientY)) {
if (isOurUi(n)) continue;
if (n instanceof SVGSVGElement) return n;
if (n instanceof SVGElement) return outermostSvg(n);
if (n.matches && n.matches(CLICKABLE)) {
const inner = n.querySelector('svg');
if (inner) return outermostSvg(inner);
}
}
}
return null;
}
function cleanClone(svg) {
const clone = svg.cloneNode(true);
clone.classList.remove(HOVER_CLASS);
if (clone.getAttribute('class') === '') clone.removeAttribute('class');
return clone;
}
function serializeSvg(clone) {
const c = clone.cloneNode(true);
if (!c.getAttribute('xmlns')) c.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
if (!c.getAttribute('xmlns:xlink') && c.innerHTML.includes('xlink:')) {
c.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
}
return new XMLSerializer().serializeToString(c);
}
// --- Computed-style baking ------------------------------------------------
// Icons routinely get their rendered look from page CSS (classes, CSS
// variables, currentColor) rather than their own markup, so a plain DOM
// copy pastes black or invisible. Bake the computed values into the clone
// so it renders standalone the way it rendered in the page.
// Presentation properties worth baking, with their SVG initial values —
// the baseline a detached copy falls back to. All of these inherit except
// `opacity`, whose baseline is always its initial value.
const BAKE_PROPS = {
fill: 'rgb(0, 0, 0)',
stroke: 'none',
'stroke-width': '1px',
'fill-opacity': '1',
'stroke-opacity': '1',
opacity: '1',
};
// Declared values ("#fff", "red") and computed values ("rgb(255, 255, 255)")
// aren't string-comparable, and detached elements have no computed style.
// Normalize declared values through a hidden in-document element.
function makeStyleProbe() {
const holder = document.createElementNS(SVG_NS, 'svg');
holder.setAttribute('aria-hidden', 'true');
holder.style.cssText = 'position:fixed;width:0;height:0;overflow:hidden;visibility:hidden';
const el = document.createElementNS(SVG_NS, 'g');
holder.appendChild(el);
document.documentElement.appendChild(holder);
return { holder, el };
}
function bakeComputedStyles(svg, clone) {
// cloneNode(true) preserves structure, so the trees walk in lockstep:
// read computed style from the live element, write onto its clone twin.
const srcEls = [svg, ...svg.querySelectorAll('*')];
const dstEls = [clone, ...clone.querySelectorAll('*')];
const rootColor = getComputedStyle(svg).getPropertyValue('color');
let usesCurrentColor = false;
const probe = makeStyleProbe();
try {
for (let i = 0; i < srcEls.length; i++) {
const src = srcEls[i];
const dst = dstEls[i];
// Non-rendered subtrees have no meaningful computed style.
if (src !== svg && src.closest('defs, symbol, title, desc, metadata')) continue;
const cs = getComputedStyle(src);
const parentCs = src === svg ? null : getComputedStyle(src.parentElement);
for (const prop of Object.keys(BAKE_PROPS)) {
const inline = dst.style.getPropertyValue(prop).trim();
const attr = (dst.getAttribute(prop) || '').trim();
const declared = inline || attr;
if (/^currentcolor$/i.test(declared)) {
// Keep currentColor themable; bake the color it resolves to as a
// `color` attribute instead (on the root below — here only when
// this element's color differs from the root's).
usesCurrentColor = true;
const ownColor = cs.getPropertyValue('color');
if (ownColor !== rootColor && !dst.style.getPropertyValue('color')) {
dst.setAttribute('color', ownColor);
}
continue;
}
const computed = cs.getPropertyValue(prop);
if (!computed || computed.includes('url(')) continue; // paint-server refs: leave as-is
if (declared && !declared.includes('var(')) {
// Markup already declares a concrete value; bake only when page
// CSS overrides it (stylesheets beat presentation attributes).
probe.el.style.cssText = '';
probe.el.style.setProperty(prop, declared);
if (getComputedStyle(probe.el).getPropertyValue(prop) === computed) continue;
} else if (!declared) {
// Undeclared: bake only if the standalone copy would inherit or
// default to something different from what the page renders.
const baseline = (src === svg || prop === 'opacity')
? BAKE_PROPS[prop]
: parentCs.getPropertyValue(prop);
if (computed === baseline) continue;
}
// (declared with var(...) falls through: bake the resolved value.)
dst.setAttribute(prop, computed);
if (inline) dst.style.removeProperty(prop);
}
if (dst.getAttribute('style') === '') dst.removeAttribute('style');
}
if (usesCurrentColor) {
// The computed color already accounts for any inline color, so the
// attribute form can safely replace it.
clone.style.removeProperty('color');
clone.setAttribute('color', rootColor);
if (clone.getAttribute('style') === '') clone.removeAttribute('style');
}
} finally {
probe.holder.remove();
}
}
// --- <use> reference inlining ----------------------------------------------
// Sprite-based icons (<use href="#id"> or <use href="/sprite.svg#id">)
// serialize to an empty shell because the referenced <symbol> lives
// elsewhere. Pull referenced fragments into the clone's <defs> so the copy
// is self-contained. Cross-origin sprites blocked by CORS stay untouched
// and are counted so the toast can say so.
const spriteCache = new Map(); // absolute sprite URL -> Promise<Document|null>
function fetchSpriteDoc(url) {
if (!spriteCache.has(url)) {
spriteCache.set(url, fetch(url, { credentials: 'same-origin' })
.then((res) => (res.ok ? res.text() : null))
.then((text) => {
if (!text) return null;
const doc = new DOMParser().parseFromString(text, 'image/svg+xml');
return doc.querySelector('parsererror') ? null : doc;
})
.catch(() => null));
}
return spriteCache.get(url);
}
function useHref(use) {
return (use.getAttribute('href') || use.getAttributeNS(XLINK_NS, 'href') || use.getAttribute('xlink:href') || '').trim();
}
function cloneOwnsId(clone, id) {
try {
return !!clone.querySelector(`#${CSS.escape(id)}`);
} catch (err) {
return false;
}
}
function defsOf(clone) {
for (const child of clone.children) {
if (child.localName === 'defs') return child;
}
const defs = document.createElementNS(SVG_NS, 'defs');
clone.insertBefore(defs, clone.firstChild);
return defs;
}
async function inlineUseRefs(svg, clone) {
const failed = new Set();
// Inlined fragments can contain <use> themselves; extra passes resolve
// nested chains, and cloneOwnsId() keeps cycles from re-importing.
for (let pass = 0; pass < 4; pass++) {
let progressed = false;
for (const use of [...clone.querySelectorAll('use')]) {
const href = useHref(use);
const hash = href.indexOf('#');
if (hash < 0) continue;
let id = href.slice(hash + 1);
try { id = decodeURIComponent(id); } catch (err) { /* keep raw */ }
if (!id) continue;
const external = hash > 0;
if (!cloneOwnsId(clone, id)) {
let node = null;
try {
if (external) {
const doc = await fetchSpriteDoc(new URL(href.slice(0, hash), document.baseURI).href);
node = doc && doc.querySelector(`#${CSS.escape(id)}`);
} else {
// Same-document reference; the svg may live in a shadow root
// while the sprite sits in the main document, so check both.
const root = svg.getRootNode();
node = (typeof root.getElementById === 'function' && root.getElementById(id)) || document.getElementById(id);
}
} catch (err) { node = null; }
if (!node) {
failed.add(href);
continue; // leave the href untouched so the copy still points somewhere
}
const imported = node.cloneNode(true);
for (const script of imported.querySelectorAll('script')) script.remove();
defsOf(clone).appendChild(imported);
progressed = true;
}
if (external) {
use.setAttribute('href', `#${id}`);
if (use.getAttributeNS(XLINK_NS, 'href') || use.getAttribute('xlink:href')) {
use.setAttributeNS(XLINK_NS, 'xlink:href', `#${id}`);
}
}
failed.delete(href);
}
if (!progressed) break;
}
return failed.size;
}
function makePreviewNode(clone) {
const p = clone.cloneNode(true);
p.removeAttribute('id');
// Without a viewBox the chip's forced 40px sizing would crop instead of
// scale; synthesize one from the declared dimensions when possible.
if (!p.getAttribute('viewBox')) {
const w = parseFloat(p.getAttribute('width'));
const h = parseFloat(p.getAttribute('height'));
if (w > 0 && h > 0) p.setAttribute('viewBox', `0 0 ${w} ${h}`);
}
return p;
}
function copyText(text) {
try {
if (typeof GM_setClipboard === 'function') {
GM_setClipboard(text, 'text');
return Promise.resolve();
}
} catch (err) { /* fall through to navigator.clipboard */ }
return navigator.clipboard.writeText(text);
}
function onMove(e) {
const svg = svgFromEvent(e);
if (svg === hovered) return;
if (hovered) hovered.classList.remove(HOVER_CLASS);
hovered = svg;
if (hovered) hovered.classList.add(HOVER_CLASS);
}
// Swallow the whole click sequence whenever the click resolves to an SVG
// (including one buried in a button), so the page's own handlers never
// fire. Clicks that don't resolve to an SVG pass through untouched, so
// menus and dropdowns can still be opened while the picker stays armed.
function onSwallow(e) {
if (isOurUi(e.target) || svgFromEvent(e)) {
e.preventDefault();
e.stopImmediatePropagation();
}
}
async function copySvg(svg) {
const clone = cleanClone(svg);
// Bake styles first: it walks the original and clone trees in lockstep,
// so it must run before inlining adds <defs> the original doesn't have.
try {
bakeComputedStyles(svg, clone);
} catch (err) { /* best-effort: a hostile stylesheet shouldn't block the copy */ }
let note = '';
try {
const missing = await inlineUseRefs(svg, clone);
if (missing) note = `, ${missing} <use> ref${missing === 1 ? '' : 's'} not inlined`;
} catch (err) { /* best-effort */ }
const markup = serializeSvg(clone);
try {
await copyText(markup);
toast(`SVG copied — ${markup.length} chars${note}`, makePreviewNode(clone));
} catch (err) {
toast(`Copy failed: ${err}`);
}
}
function onClick(e) {
if (isOurUi(e.target)) {
e.preventDefault();
e.stopImmediatePropagation();
setActive(false); // clicking the HUD pill exits picker mode
return;
}
const svg = svgFromEvent(e);
if (!svg) return;
e.preventDefault();
e.stopImmediatePropagation();
// Sticky: stay active so more icons can be picked without re-toggling.
copySvg(svg);
}
function showHud(on) {
if (on) {
if (!hudEl) {
hudEl = document.createElement('div');
hudEl.id = HUD_ID;
const dot = document.createElement('span');
dot.className = '__svgclipper_dot';
const label = document.createElement('span');
label.textContent = 'SVG Clipper — click an icon to copy';
const x = document.createElement('span');
x.className = '__svgclipper_x';
x.textContent = '✕';
hudEl.append(dot, label, x);
hudEl.title = 'Click to exit (or press Esc / Alt+Shift+S)';
}
if (!hudEl.isConnected) (document.body || document.documentElement).appendChild(hudEl);
} else if (hudEl) {
hudEl.remove();
}
}
function setActive(on) {
if (on === active) return;
active = on;
document.documentElement.classList.toggle(ACTIVE_CLASS, on);
showHud(on);
const fn = on ? 'addEventListener' : 'removeEventListener';
document[fn]('mousemove', onMove, true);
document[fn]('click', onClick, true);
for (const type of ['pointerdown', 'pointerup', 'mousedown', 'mouseup']) {
document[fn](type, onSwallow, true);
}
if (!on && hovered) {
hovered.classList.remove(HOVER_CLASS);
hovered = null;
}
if (on) toast('SVG Clipper on — stays active until Esc, Alt+Shift+S, or the ✕ pill');
else toast('SVG Clipper off');
}
document.addEventListener('keydown', (e) => {
if (e.altKey && e.shiftKey && e.code === 'KeyS') {
e.preventDefault();
e.stopImmediatePropagation();
setActive(!active);
} else if (active && e.key === 'Escape') {
e.preventDefault();
e.stopImmediatePropagation();
setActive(false);
}
}, true);
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('Toggle SVG Clipper (Alt+Shift+S)', () => setActive(!active));
}
})();