Greasy Fork

Greasy Fork is available in English.

X 图片悬停预览

悬停在图片上可在屏幕中间显示原图;多图推文中滚轮可切换前后图片;视频/GIF 缩略图不会预览。

当前为 2025-07-05 提交的版本,查看 最新版本

// ==UserScript==
// @name           X(旧Twitter)画像プレビュー
// @name:en        X Image Hover Preview
// @name:zh-CN     X 图片悬停预览
// @namespace      https://github.com/yourname/TwitterImageHoverPreview
// @version        1.0
// @description    写真にマウスを乗せると原寸プレビューを中央表示。複数画像ツイートではホイールで前後の画像に切替。動画・GIFサムネは対象外。
// @description:en Hover over a photo on X (Twitter) to see a full‑size preview. Scroll wheel to switch between images in multi‑photo tweets. Video/GIF thumbnails are ignored.
// @description:zh-CN 悬停在图片上可在屏幕中间显示原图;多图推文中滚轮可切换前后图片;视频/GIF 缩略图不会预览。
// @author         @pueka_3
// @match          https://twitter.com/*
// @match          https://x.com/*
// @icon           https://x.com/favicon.ico
// @grant          GM_addStyle
// @license MIT
// ==/UserScript==

(function () {
  'use strict';

  const PREVIEW_ID = 'tm-hover-preview';
  const BORDER_PX = 2;

  /** State for wheel navigation */
  let currentGallery = [];
  let currentIndex = 0;
  let wheelBind = false;

  // ───────────────────────────────────────── Styles
  GM_addStyle(`
    #${PREVIEW_ID} {
      position: fixed;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      max-width: 70vw;
      max-height: 80vh;
      border: ${BORDER_PX}px solid #fff;
      box-shadow: 0 0 8px rgba(0, 0, 0, .5);
      z-index: 999999;
      pointer-events: none;
      display: none;
      background: #000;
      opacity: 0;
      transition: opacity .15s ease-out;
    }
  `);

  // ──────────────────────────────────────── Helpers
  function ensurePreview() {
    let el = document.getElementById(PREVIEW_ID);
    if (!el) {
      el = document.createElement('img');
      el.id = PREVIEW_ID;
      document.body.appendChild(el);
    }
    return el;
  }

  /** Convert thumbnail URL to original quality */
  function toOrig(url) {
    try {
      const u = new URL(url);
      if (u.searchParams.has('name')) u.searchParams.set('name', 'orig');
      return u.toString().replace(/:(?:small|medium|large|orig)$/i, ':orig');
    } catch (_) {
      return url;
    }
  }

  /** True if img *belongs to* a video player (should be skipped). */
  function isVideoContext(img) {
    return (
      img.closest('[data-testid="videoPlayer"], [data-testid="videoPlayerThumbnail"]') ||
      img.closest('[aria-label*="動画" i], [aria-label*="video" i]') ||
      img.closest('article')?.querySelector('video')
    );
  }

  /** Return true if URL is a photo (jpg/png/webp), false for video / gif thumbs */
  function isPhotoUrl(url) {
    let u;
    try { u = new URL(url); } catch { return false; }

    // Reject video-related paths
    if (/(?:^|\/)(?:amplify|ext_tw|tweet)_video(?:_|\/|$)/i.test(u.pathname)) return false;
    if (/video_thumb|animated_gif/i.test(u.pathname)) return false;

    // Query-param check
    const mime = u.searchParams.get('mimetype');
    if (mime && mime.startsWith('video')) return false;
    const fmt = u.searchParams.get('format');
    if (fmt) return /^(?:jpe?g|png|webp)$/i.test(fmt);

    // File extension fallback
    return /\.(?:jpe?g|png|webp)$/i.test(u.pathname);
  }

  /** Collect all photo URLs in the same tweet (gallery) for wheel navigation */
  function collectGallery(img) {
    const article = img.closest('article');
    if (!article) return [toOrig(img.src)];

    const imgs = Array.from(article.querySelectorAll('img'));
    const urls = [];
    for (const i of imgs) {
      const url = toOrig(i.src);
      if (!url.includes('/media/')) continue; // only media images
      if (isPhotoUrl(url) && !isVideoContext(i) && !urls.includes(url)) urls.push(url);
    }
    return urls.length ? urls : [toOrig(img.src)];
  }

  // ───────────────────────────────────────── Wheel Handler
  function onWheel(e) {
    if (currentGallery.length <= 1) return;
    e.preventDefault();

    currentIndex = (currentIndex + (e.deltaY > 0 ? 1 : -1) + currentGallery.length) % currentGallery.length;
    const nextSrc = currentGallery[currentIndex];

    const preview = ensurePreview();
    preview.style.opacity = '0';

    const buffer = new Image();
    buffer.onload = () => {
      preview.src = buffer.src;
      void preview.offsetWidth;
      preview.style.opacity = '1';
    };
    buffer.src = nextSrc;
  }

  function bindWheel() {
    if (!wheelBind) {
      window.addEventListener('wheel', onWheel, { passive: false });
      wheelBind = true;
    }
  }
  function unbindWheel() {
    if (wheelBind) {
      window.removeEventListener('wheel', onWheel, { passive: false });
      wheelBind = false;
    }
  }

  // ───────────────────────────────────────── Events
  function showPreview(e) {
    const img = /** @type {HTMLImageElement} */ (e.currentTarget);

    if (isVideoContext(img)) { hidePreview(); return; }

    const src = toOrig(img.src);
    if (!isPhotoUrl(src)) { hidePreview(); return; }

    currentGallery = collectGallery(img);
    currentIndex = currentGallery.indexOf(src);
    if (currentIndex === -1) currentIndex = 0;

    const preview = ensurePreview();

    const buffer = new Image();
    buffer.onload = () => {
      preview.src = buffer.src;
      preview.style.display = 'block';
      void preview.offsetWidth;
      preview.style.opacity = '1';
      bindWheel();
    };
    buffer.src = src;

    preview.style.opacity = '0';
  }

  function hidePreview() {
    const p = document.getElementById(PREVIEW_ID);
    if (p) {
      p.style.opacity = '0';
      p.addEventListener('transitionend', () => { if (p.style.opacity === '0') p.style.display = 'none'; }, { once: true });
    }
    unbindWheel();
    currentGallery = [];
  }

  // ─────────────────────────────────── Binding & Observer
  function bind(img) {
    if (img.dataset.tmHoverBound) return;
    img.dataset.tmHoverBound = '1';
    img.addEventListener('mouseenter', showPreview);
    img.addEventListener('mouseleave', hidePreview);
  }

  const obs = new MutationObserver((mut) => {
    for (const m of mut) {
      for (const node of m.addedNodes) {
        if (!(node instanceof HTMLElement)) continue;
        if (node.tagName === 'IMG') bind(node);
        node.querySelectorAll?.('img').forEach(bind);
      }
    }
  });
  obs.observe(document.body, { childList: true, subtree: true });

  document.querySelectorAll('img').forEach(bind);
})();