Easy Image Downloader (hover button)

Adds a small download button on images; click to save the original image (uses GM_download when available).

// ==UserScript==
// @name         Easy Image Downloader (hover button)
// @namespace    https://greasyfork.org/en/users/your-name
// @version      1.0.0
// @description  Adds a small download button on images; click to save the original image (uses GM_download when available).
// @author       you
// @license      MIT
// @match        *://*/*
// @exclude      *://greasyfork.org/*
// @run-at       document-idle
// @grant        GM_download
// ==/UserScript==

(function () {
  "use strict";

  // ========== Config ==========
  const MIN_W = 120;                 // only show button on images >= MIN_W x MIN_H
  const MIN_H = 120;
  const BUTTON_TEXT = "↓";
  const BUTTON_CLASS = "eid-btn";
  const BUTTON_STYLES = `
    .${BUTTON_CLASS} {
      position: absolute;
      right: 6px;
      bottom: 6px;
      z-index: 2147483647;
      padding: 4px 8px;
      font: 600 12px/1 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
      background: rgba(0,0,0,.75);
      color: #fff;
      border: 0;
      border-radius: 6px;
      cursor: pointer;
      opacity: 0;
      transition: opacity .15s ease-in-out, transform .15s ease-in-out;
      transform: translateY(2px);
      user-select: none;
    }
    .eid-wrap:hover .${BUTTON_CLASS} { opacity: 1; transform: translateY(0); }
  `;

  // Inject CSS
  const style = document.createElement("style");
  style.textContent = BUTTON_STYLES;
  document.documentElement.appendChild(style);

  // Observe images added later
  const observer = new MutationObserver(() => decorateAll());
  observer.observe(document.documentElement, { childList: true, subtree: true });

  // Initial pass
  decorateAll();

  function decorateAll() {
    const imgs = document.querySelectorAll("img:not([data-eid])");
    for (const img of imgs) {
      // Skip tiny/hidden images
      const w = img.naturalWidth || img.width;
      const h = img.naturalHeight || img.height;
      if (w < MIN_W || h < MIN_H) {
        img.setAttribute("data-eid", "skip");
        continue;
      }

      // Wrap the image in a relatively positioned container (non-destructive)
      const wrap = document.createElement("span");
      wrap.className = "eid-wrap";
      wrap.style.position = "relative";
      wrap.style.display = "inline-block";

      // Some sites have display:block on images; preserve layout width/height
      wrap.style.width = img.width ? img.width + "px" : "";
      wrap.style.height = img.height ? img.height + "px" : "";

      // Insert wrapper and move the image inside
      img.parentNode && img.parentNode.insertBefore(wrap, img);
      wrap.appendChild(img);

      // Create button
      const btn = document.createElement("button");
      btn.type = "button";
      btn.className = BUTTON_CLASS;
      btn.textContent = BUTTON_TEXT;
      btn.title = "Download image";
      btn.addEventListener("click", (ev) => {
        ev.preventDefault();
        ev.stopPropagation();
        const url = resolveImageURL(img);
        const filename = suggestFilename(img, url);
        downloadImage(url, filename);
      });

      wrap.appendChild(btn);
      img.setAttribute("data-eid", "done");
    }
  }

  // Try to get the highest quality URL if the site uses srcset
  function resolveImageURL(img) {
    // Prefer currentSrc when available (handles srcset)
    if (img.currentSrc) return img.currentSrc;
    return img.src || "";
  }

  function suggestFilename(img, url) {
    try {
      const u = new URL(url, location.href);
      const pathName = u.pathname.split("/").pop() || "image";
      const clean = decodeURIComponent(pathName).split("?")[0].split("#")[0];
      const base = clean || (img.alt ? sluggify(img.alt) : "image");
      const ext = guessExtFromURL(url) || "jpg";
      return ensureExt(base, ext);
    } catch {
      const base = (img.alt ? sluggify(img.alt) : "image");
      return ensureExt(base, "jpg");
    }
  }

  function sluggify(s) {
    return s
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, "-")
      .replace(/^-+|-+$/g, "")
      .slice(0, 64) || "image";
  }

  function guessExtFromURL(url) {
    const m = url.toLowerCase().match(/\.(png|jpe?g|webp|gif|bmp|svg|avif)(?:$|\?|\#)/);
    return m ? m[1].replace("jpeg", "jpg") : null;
  }

  function ensureExt(name, ext) {
    if (!name.toLowerCase().endsWith(`.${ext}`)) return `${name}.${ext}`;
    return name;
  }

  async function downloadImage(url, filename) {
    // Prefer GM_download when available (cross-origin friendly)
    if (typeof GM_download === "function") {
      try {
        GM_download({
          url,
          name: filename,
          headers: { Referer: location.href }, // helps on some hosts
          onerror: () => fallbackDownload(url, filename),
        });
        return;
      } catch {
        // fall through
      }
    }
    // Fallback
    fallbackDownload(url, filename);
  }

  async function fallbackDownload(url, filename) {
    try {
      // If same-origin or CORS allowed
      const res = await fetch(url, { credentials: "omit" });
      const blob = await res.blob();
      const a = document.createElement("a");
      const objectUrl = URL.createObjectURL(blob);
      a.href = objectUrl;
      a.download = filename;
      document.body.appendChild(a);
      a.click();
      setTimeout(() => {
        URL.revokeObjectURL(objectUrl);
        a.remove();
      }, 1000);
    } catch (e) {
      // Last resort: open in a new tab (user can save manually)
      window.open(url, "_blank", "noopener,noreferrer");
      console.warn("[Easy Image Downloader] Fallback open:", e);
    }
  }
})();