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.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

Advertisement:

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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