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.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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