PxHance

Hover Pixiv thumbnails to show a zoomed preview, scroll to view multiple pages, with single/all download options inside the blurred container. Click image to go to artwork page.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         PxHance
// @namespace    https://pixiv.net/
// @version      1.0.0
// @description  Hover Pixiv thumbnails to show a zoomed preview, scroll to view multiple pages, with single/all download options inside the blurred container. Click image to go to artwork page.
// @match        https://www.pixiv.net/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=pixiv.net
// @grant        GM_addStyle
// @grant        GM_download
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @run-at       document-start
// @license MIT
// ==/UserScript==

(() => {
  // biome-ignore lint/suspicious/noRedundantUseStrict: <explanation>
  "use strict";

  const DEBUG = false;

  const DEFAULTS = {
    HOVER_DELAY: 160,
    LEAVE_DELAY: 140,
    ZOOM_SCALE: 2.5,
    DOWNLOAD_DELAY: 300,
  };

  // load config
  const CONFIG = {
    HOVER_DELAY: GM_getValue("HOVER_DELAY", DEFAULTS.HOVER_DELAY),
    LEAVE_DELAY: GM_getValue("LEAVE_DELAY", DEFAULTS.LEAVE_DELAY),
    ZOOM_SCALE: GM_getValue("ZOOM_SCALE", DEFAULTS.ZOOM_SCALE),
    DOWNLOAD_DELAY: GM_getValue("DOWNLOAD_DELAY", DEFAULTS.DOWNLOAD_DELAY),
  };

  function registerMenu() {
    GM_registerMenuCommand("Set Hover Delay", () => {
      const val = prompt("Hover Delay (ms):", CONFIG.HOVER_DELAY);
      if (val !== null) {
        GM_setValue("HOVER_DELAY", Number(val));
        location.reload();
      }
    });

    GM_registerMenuCommand("Set Leave Delay", () => {
      const val = prompt("Leave Delay (ms):", CONFIG.LEAVE_DELAY);
      if (val !== null) {
        GM_setValue("LEAVE_DELAY", Number(val));
        location.reload();
      }
    });

    GM_registerMenuCommand("Set Zoom Scale", () => {
      const val = prompt("Zoom Scale:", CONFIG.ZOOM_SCALE);
      if (val !== null) {
        GM_setValue("ZOOM_SCALE", Number(val));
        location.reload();
      }
    });

    GM_registerMenuCommand("Set Download Delay", () => {
      const val = prompt("Download Delay (ms):", CONFIG.DOWNLOAD_DELAY);
      if (val !== null) {
        GM_setValue("DOWNLOAD_DELAY", Number(val));
        location.reload();
      }
    });

    GM_registerMenuCommand("Reset Defaults", () => {
      Object.keys(DEFAULTS).forEach((k) => GM_setValue(k, DEFAULTS[k]));
      location.reload();
    });
  }

  registerMenu();

  let hoverTimer = null;
  let leaveTimer = null;
  let active = null;
  let tokenSeq = 0;

  const originalCache = new Map();

  function log(...args) {
    if (DEBUG) console.log("[PixivHover]", ...args);
  }
  function warn(...args) {
    if (DEBUG) console.warn("[PixivHover]", ...args);
  }
  function err(...args) {
    if (DEBUG) console.error("[PixivHover]", ...args);
  }

  GM_addStyle(`
    .px-hover-layer {
      position: fixed;
      z-index: 2147483647;
      box-sizing: border-box;
      overflow: hidden;
      border-radius: 8px;
      background: rgba(18, 18, 18, 0.75);
      border: 1px solid rgba(255,255,255,0.16);
      box-shadow: 0 18px 60px rgba(0,0,0,0.45);
      backdrop-filter: blur(10px);
      -webkit-backdrop-filter: blur(10px);
      transform-origin: center center;
      transform: scale(0.96);
      opacity: 0;
      transition: transform 0.12s ease, opacity 0.12s ease;
      pointer-events: none;
      display: flex;
      flex-direction: column;
    }

    .px-hover-layer.px-show {
      transform: scale(1);
      opacity: 1;
      pointer-events: auto;
    }

    .px-hover-img-container {
      flex: 1;
      overflow: hidden;
      position: relative;
    }

    /* 新增:图片链接层的样式 */
    .px-hover-link-wrapper {
        display: block;
        width: 100%;
        height: 100%;
        text-decoration: none;
        cursor: pointer; /* 提示可点击 */
        outline: none;
    }

    .px-hover-layer img {
      display: block;
      width: 100%;
      height: 100%;
      object-fit: contain !important;
      object-position: center center !important;
      user-select: none;
      -webkit-user-drag: none;
      background: transparent;
      pointer-events: none; /* 让点击穿透到 parent a 标签 */
    }

    .px-hover-controls {
      position: absolute;
      bottom: 16px;
      left: 50%;
      transform: translateX(-50%);
      display: flex;
      align-items: center;
      gap: 10px;
      background: rgba(20, 20, 20, 0.35);
      backdrop-filter: blur(10px);
      border: 1px solid rgba(255,255,255,0.18);
      border-radius: 8px;
      padding: 6px 14px;
      box-shadow: 0 8px 28px rgba(0,0,0,0.35);
      opacity: 0;
      pointer-events: none;
      transition: opacity 0.12s ease;
      z-index: 10;
    }

    .px-hover-controls.px-show {
      opacity: 1;
      pointer-events: auto;
    }

    .px-page-indicator {
      color: #fff;
      font-size: 13px;
      font-weight: bold;
      font-family: monospace;
      min-width: 45px;
      text-align: center;
      user-select: none;
    }

    .px-btn {
      background: rgba(255, 255, 255, 0.1);
      color: #fff;
      border: none;
      border-radius: 8px;
      padding: 4px 10px;
      font-size: 12px;
      cursor: pointer;
      transition: background 0.1s;
    }

    .px-btn:hover { background: rgba(255, 255, 255, 0.25); }
    .px-btn:active { background: rgba(255, 255, 255, 0.4); }

    img[data-px-hoverable="1"] {
      cursor: zoom-in !important;
    }
  `);

  function getThumbUrl(img) {
    return (
      img.currentSrc ||
      img.getAttribute("src") ||
      img.getAttribute("data-src") ||
      img.getAttribute("data-original") ||
      img.getAttribute("srcset")?.split(" ")[0] ||
      ""
    );
  }

  function getIllustIdFromElement(el) {
    const a = el.closest?.('a[href*="/artworks/"]');
    if (a) {
      const m = (a.getAttribute("href") || "").match(/\/artworks\/(\d+)/);
      if (m) return m[1];
    }
    const gtm = el.closest?.("[data-gtm-value]");
    if (gtm) {
      const v = gtm.getAttribute("data-gtm-value");
      if (v && /^\d+$/.test(v)) return v;
    }
    return null;
  }

  function isLikelyArtworkThumb(target) {
    return (
      target instanceof HTMLImageElement &&
      Boolean(getIllustIdFromElement(target))
    );
  }

  function loadImage(url, timeoutMs = 15000) {
    return new Promise((resolve, reject) => {
      const test = new Image();
      let done = false;
      const finish = (ok) => {
        if (done) return;
        done = true;
        clearTimeout(timer);
        test.onload = null;
        test.onerror = null;
        ok ? resolve(url) : reject(new Error("image load failed"));
      };
      const timer = setTimeout(() => finish(false), timeoutMs);
      test.onload = () => finish(true);
      test.onerror = () => finish(false);
      test.src = url;
    });
  }

  function fetchOriginalUrlsByIllustId(illustId) {
    if (!illustId) return Promise.resolve(null);
    if (originalCache.has(illustId)) return originalCache.get(illustId);

    const p = fetch(`/ajax/illust/${illustId}/pages`, {
      credentials: "include",
      headers: { "x-requested-with": "XMLHttpRequest" },
    })
      .then(async (r) => {
        if (!r.ok) return null;
        const j = await r.json();
        const urls = j?.body?.map((page) => page.urls.original).filter(Boolean);
        return urls && urls.length > 0 ? urls : null;
      })
      .catch((e) => {
        err("fetch original urls failed", illustId, e);
        return null;
      });

    originalCache.set(illustId, p);
    return p;
  }

  function createOverlayElements() {
    const layer = document.createElement("div");
    layer.id = "px-hover-layer";
    layer.className = "px-hover-layer";

    const imgContainer = document.createElement("div");
    imgContainer.className = "px-hover-img-container";

    // --- 修改开始:创建链接包装层 ---
    const linkWrapper = document.createElement("a");
    linkWrapper.className = "px-hover-link-wrapper";
    linkWrapper.target = "_blank"; //在新窗口打开作品页
    linkWrapper.rel = "noreferrer"; //保护隐私,防止 Referer 泄漏到作品页(虽然都在 Pixiv 域名下,但这是一种好习惯)

    const preview = document.createElement("img");

    linkWrapper.appendChild(preview);
    imgContainer.appendChild(linkWrapper);
    layer.appendChild(imgContainer);
    // --- 修改结束 ---

    const controls = document.createElement("div");
    controls.className = "px-hover-controls";

    const pageInd = document.createElement("div");
    pageInd.className = "px-page-indicator";
    pageInd.textContent = "- / -";

    const btnCurrent = document.createElement("button");
    btnCurrent.className = "px-btn";
    btnCurrent.textContent = "⬇️";

    const btnAll = document.createElement("button");
    btnAll.className = "px-btn";
    btnAll.textContent = "⬇️⬇️⬇️";

    controls.appendChild(pageInd);
    controls.appendChild(btnCurrent);
    controls.appendChild(btnAll);

    layer.appendChild(controls);

    // 把 linkWrapper 也传出去,方便后面设置 href
    return {
      layer,
      controls,
      preview,
      pageInd,
      btnCurrent,
      btnAll,
      linkWrapper,
    };
  }

  function positionElements(layer, rect) {
    const w = Math.min(
      Math.max(Math.round(rect.width * CONFIG.ZOOM_SCALE), 300),
      window.innerWidth - 16,
    );
    const h = Math.min(
      Math.max(Math.round(rect.height * CONFIG.ZOOM_SCALE), 300),
      window.innerHeight - 16,
    );

    let left = rect.left + rect.width / 2 - w / 2;
    let top = rect.top + rect.height / 2 - h / 2;

    left = Math.max(8, Math.min(left, window.innerWidth - w - 8));
    top = Math.max(8, Math.min(top, window.innerHeight - h - 8));

    layer.style.left = `${left}px`;
    layer.style.top = `${top}px`;
    layer.style.width = `${w}px`;
    layer.style.height = `${h}px`;
  }

  function removeActive() {
    tokenSeq += 1;
    if (hoverTimer) clearTimeout(hoverTimer);
    if (leaveTimer) clearTimeout(leaveTimer);

    document.getElementById("px-hover-layer")?.remove();

    if (active) log("hide preview", active.illustId);
    active = null;
  }

  function executeDownload(url, illustId, index = null) {
    const defaultName =
      index !== null ? `pixiv_${illustId}_p${index}` : `pixiv_${illustId}`;
    const name = url.split("/").pop()?.split("?")[0] || defaultName;

    try {
      if (typeof GM_download === "function") {
        GM_download({
          url: url,
          name: name,
          saveAs: true,
          headers: { Referer: "https://www.pixiv.net/" },
          onerror: (e) => err("GM_download failed", e),
        });
        return;
      }
    } catch (e) {
      err("GM_download threw", e);
    }
    const a = document.createElement("a");
    a.href = url;
    a.download = name;
    a.target = "_blank";
    a.rel = "noreferrer";
    a.click();
  }

  async function showPreview(img) {
    const thumbUrl = getThumbUrl(img);
    const illustId = getIllustIdFromElement(img);
    if (!thumbUrl || !illustId) return;

    const myToken = ++tokenSeq;
    active = {
      token: myToken,
      img,
      illustId,
      thumbUrl,
      urls: [],
      currentIndex: 0,
    };

    document.getElementById("px-hover-layer")?.remove();

    const els = createOverlayElements();

    // --- 新增:设置作品页链接 ---
    els.linkWrapper.href = `/artworks/${illustId}`;

    els.preview.src = thumbUrl;

    document.documentElement.appendChild(els.layer);

    positionElements(els.layer, img.getBoundingClientRect());
    requestAnimationFrame(() => els.layer.classList.add("px-show"));

    const keepAlive = () => {
      if (leaveTimer) {
        clearTimeout(leaveTimer);
        leaveTimer = null;
      }
    };
    const setLeave = () => {
      leaveTimer = setTimeout(() => removeActive(), CONFIG.LEAVE_DELAY);
    };

    els.layer.addEventListener("pointerenter", keepAlive);
    els.layer.addEventListener("pointerleave", setLeave);

    els.layer.addEventListener(
      "wheel",
      (e) => {
        if (!active.urls || active.urls.length <= 1) return;
        e.preventDefault();

        const oldIndex = active.currentIndex;
        if (e.deltaY > 0) {
          active.currentIndex = Math.min(
            active.currentIndex + 1,
            active.urls.length - 1,
          );
        } else {
          active.currentIndex = Math.max(active.currentIndex - 1, 0);
        }

        if (oldIndex !== active.currentIndex) {
          els.preview.src = active.urls[active.currentIndex];
          els.pageInd.textContent = `${active.currentIndex + 1} / ${active.urls.length}`;
        }
      },
      { passive: false },
    );

    els.btnCurrent.addEventListener("click", (e) => {
      e.preventDefault();
      e.stopPropagation();
      if (active.urls.length === 0) return;
      executeDownload(
        active.urls[active.currentIndex],
        active.illustId,
        active.currentIndex,
      );
    });

    els.btnAll.addEventListener("click", async (e) => {
      e.preventDefault();
      e.stopPropagation();
      if (active.urls.length === 0) return;

      for (let i = 0; i < active.urls.length; i++) {
        executeDownload(active.urls[i], active.illustId, i);
        if (i < active.urls.length - 1) {
          await new Promise((r) => setTimeout(r, CONFIG.DOWNLOAD_DELAY));
        }
      }
    });

    const urls = await fetchOriginalUrlsByIllustId(illustId);
    if (!urls || active.token !== myToken) return;

    active.urls = urls;
    els.pageInd.textContent = `1 / ${urls.length}`;

    if (urls.length <= 1) els.btnAll.style.display = "none";

    requestAnimationFrame(() => els.controls.classList.add("px-show"));

    try {
      await loadImage(urls[0]);
      if (active.token === myToken && active.currentIndex === 0) {
        els.preview.src = urls[0];
      }
    } catch (e) {
      err("Original load failed", e);
    }
  }

  function scheduleShow(img) {
    if (hoverTimer) clearTimeout(hoverTimer);
    hoverTimer = setTimeout(() => showPreview(img), CONFIG.HOVER_DELAY);
  }

  function bindGlobalEvents() {
    document.addEventListener(
      "pointerover",
      (e) => {
        const target =
          e.target instanceof Element ? e.target.closest("img") : null;
        if (!isLikelyArtworkThumb(target)) return;

        target.dataset.pxHoverable = "1";
        if (leaveTimer) {
          clearTimeout(leaveTimer);
          leaveTimer = null;
        }
        scheduleShow(target);
      },
      true,
    );

    document.addEventListener(
      "pointerout",
      (e) => {
        const fromImg =
          e.target instanceof Element ? e.target.closest("img") : null;
        if (!isLikelyArtworkThumb(fromImg)) return;

        const rel = e.relatedTarget;
        if (rel instanceof Node && fromImg.contains(rel)) return;

        if (hoverTimer) {
          clearTimeout(hoverTimer);
          hoverTimer = null;
        }
        if (leaveTimer) clearTimeout(leaveTimer);
        leaveTimer = setTimeout(() => removeActive(), CONFIG.LEAVE_DELAY);
      },
      true,
    );

    window.addEventListener(
      "scroll",
      () => {
        const layer = document.getElementById("px-hover-layer");
        if (layer && active?.img)
          positionElements(layer, active.img.getBoundingClientRect());
      },
      { passive: true },
    );
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", bindGlobalEvents, {
      once: true,
    });
  } else {
    bindGlobalEvents();
  }
})();