One-Click Image Downloader

Add a small badge to download images; also a floating button to download the largest image on the page.

// ==UserScript==
// @name         One-Click Image Downloader
// @namespace    Tunc
// @version      1.0.0
// @description  Add a small badge to download images; also a floating button to download the largest image on the page.
// @author       Me
// @license      MIT
// @match        *://*/*
// @exclude      *://greasyfork.org/*
// @grant        GM_download
// @grant        GM_addStyle
// ==/UserScript==

(function () {
  "use strict";

  const MIN_W = 200;            // minimum width for images to consider
  const MIN_H = 200;            // minimum height for images to consider
  const BADGE_TEXT = "↓";
  const BADGE_CLASS = "ocid-badge";
  const PANEL_CLASS = "ocid-panel";

  // --- Styles ---
  const css = `
    .${BADGE_CLASS}{
      position:absolute;
      top:6px; right:6px;
      font: 600 12px/1 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
      padding:6px 8px; border-radius:10px;
      background: rgba(0,0,0,.75); color:#fff;
      cursor:pointer; z-index: 2147483647;
      user-select:none; text-decoration:none;
      box-shadow: 0 2px 6px rgba(0,0,0,.25);
    }
    .${BADGE_CLASS}:hover{ background: rgba(0,0,0,.9) }

    .${PANEL_CLASS}{
      position: fixed; inset: auto 16px 16px auto;
      display:flex; gap:8px; align-items:center;
      background: rgba(0,0,0,.7); color:#fff;
      border-radius: 12px; padding:10px 12px;
      z-index: 2147483647; backdrop-filter: blur(4px);
      font: 600 13px/1.2 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
    }
    .${PANEL_CLASS} button{
      border:0; border-radius:10px; padding:8px 10px; cursor:pointer;
      background: #fff; color:#111; font-weight:700;
    }
    .ocid-relative{ position: relative !important; }
  `;
  if (typeof GM_addStyle === "function") GM_addStyle(css);
  else {
    const s = document.createElement("style");
    s.textContent = css;
    document.documentElement.appendChild(s);
  }

  // --- Helpers ---
  const toFilename = (url, fallbackBase = "image") => {
    try {
      const u = new URL(url, location.href);
      const base = u.pathname.split("/").pop() || fallbackBase;
      const clean = base.split("?")[0].split("#")[0];
      // Ensure an extension exists
      const hasExt = /\.[a-z0-9]{2,5}$/i.test(clean);
      return hasExt ? clean : `${clean || fallbackBase}.jpg`;
    } catch {
      return `${fallbackBase}.jpg`;
    }
  };

  const canGMDownload = typeof GM_download === "function";

  const download = (url, name) => {
    if (canGMDownload) {
      try {
        GM_download({ url, name, saveAs: true });
        return;
      } catch (e) {
        // fall through to a-tag
      }
    }
    const a = document.createElement("a");
    a.href = url;
    a.download = name;
    a.rel = "noopener";
    document.body.appendChild(a);
    a.click();
    a.remove();
  };

  const getImgSrc = (img) => {
    // Prefer currentSrc (resolves srcset); fall back to src; consider common lazy attributes
    return img.currentSrc || img.src || img.getAttribute("data-src") || img.getAttribute("data-lazy-src");
  };

  const qualifies = (img) => {
    // Use rendered size when possible
    const w = Math.max(img.naturalWidth || 0, img.clientWidth || 0);
    const h = Math.max(img.naturalHeight || 0, img.clientHeight || 0);
    return w >= MIN_W && h >= MIN_H;
  };

  const ensureRelative = (el) => {
    // So the badge can be absolutely positioned inside the image box
    const s = getComputedStyle(el);
    if (!["relative", "absolute", "fixed"].includes(s.position)) {
      el.classList.add("ocid-relative");
    }
  };

  // --- Badge injection on images ---
  const addBadge = (img) => {
    if (!qualifies(img) || img.dataset.ocidBadged) return;
    const src = getImgSrc(img);
    if (!src) return;

    const wrapTarget = img.parentElement || img;
    ensureRelative(wrapTarget);

    const badge = document.createElement("div");
    badge.textContent = BADGE_TEXT;
    badge.className = BADGE_CLASS;
    badge.title = "Download image";
    badge.addEventListener("click", (e) => {
      e.preventDefault();
      e.stopPropagation();
      const url = getImgSrc(img);
      if (!url) return;
      const name = toFilename(url, "image");
      download(url, name);
    });

    // Keep badge within the same stacking context as the image
    wrapTarget.appendChild(badge);
    img.dataset.ocidBadged = "1";
  };

  const scan = () => {
    const imgs = Array.from(document.images || []);
    imgs.forEach(addBadge);
  };

  // --- Floating panel (download the largest image) ---
  const createPanel = () => {
    if (document.querySelector(`.${PANEL_CLASS}`)) return;
    const panel = document.createElement("div");
    panel.className = PANEL_CLASS;

    const btnLargest = document.createElement("button");
    btnLargest.textContent = "Download largest image";
    btnLargest.title = "Find the biggest visible image and download it";

    btnLargest.addEventListener("click", () => {
      const imgs = Array.from(document.images || []).filter(qualifies);
      if (!imgs.length) {
        alert("No large images found on this page.");
        return;
      }
      // Score by area; prefer natural over client to get original size when possible
      const best = imgs.reduce((a, b) => {
        const area = (x) =>
          Math.max(x.naturalWidth || 0, x.clientWidth || 0) *
          Math.max(x.naturalHeight || 0, x.clientHeight || 0);
        return area(b) > area(a) ? b : a;
      }, imgs[0]);

      const url = getImgSrc(best);
      if (!url) {
        alert("Couldn't resolve image URL.");
        return;
      }
      const name = toFilename(url, "largest");
      download(url, name);
    });

    panel.appendChild(btnLargest);
    document.documentElement.appendChild(panel);
  };

  // Initial run and observers
  const onReady = () => {
    createPanel();
    scan();

    // Watch for dynamically loaded images (SPA, infinite scroll)
    const obs = new MutationObserver(() => scan());
    obs.observe(document.documentElement, { childList: true, subtree: true });

    // Re-scan when images load to get proper dimensions
    window.addEventListener("load", scan, { once: true });
    document.addEventListener("load", (e) => {
      const t = e.target;
      if (t && t.tagName === "IMG") addBadge(t);
    }, true);
  };

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", onReady);
  } else {
    onReady();
  }
})();