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.

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği yüklemek için Tampermonkey gibi bir uzantı yüklemeniz gerekir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

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