SVG Clipper

Pick any SVG on a page and copy portable markup with inlined <use> sprites and resolved CSS colors. Toggle with Alt+Shift+S.

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)

Advertisement:

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)

Advertisement:

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