GitHub README Image Viewer

Preview GitHub markdown images in a viewer with switching, wheel zoom, and drag pan.

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.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

// ==UserScript==
// @name         GitHub README Image Viewer
// @namespace    https://github.com/
// @version      1.0.4
// @description  Preview GitHub markdown images in a viewer with switching, wheel zoom, and drag pan.
// @author       Tommy
// @match        https://github.com/*
// @run-at       document-idle
// @grant        GM_addStyle
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  const ROOT_SELECTORS = [
    'article.markdown-body',
    '.markdown-body',
    '.js-comment-body',
    '[data-testid="markdown-body"]'
  ];

  const BADGE_HOSTS = [
    'shields.io',
    'badgen.net',
    'badge.fury.io',
    'poser.pugx.org',
    'nodei.co'
  ];
  const BADGE_TEXT_PATTERN = /(?:badge|shield|build|status|ci|coverage|version|license|npm|pypi|downloads|release|codecov|coveralls|sonarcloud|quality|dependencies|dependabot)/i;

  const PREVIEW_MIN_DIMENSION = 100;
  const MIN_SCALE = 0.2;
  const MAX_SCALE = 8;
  const ZOOM_STEP = 1.16;

  let imageItems = [];
  let currentIndex = 0;
  let viewer = null;
  let scanTimer = 0;
  let previousBodyOverflow = '';
  let state = {
    scale: 1,
    x: 0,
    y: 0,
    dragging: false,
    dragStartX: 0,
    dragStartY: 0,
    startX: 0,
    startY: 0
  };

  const styleText = `
    .ghiv-ready { cursor: zoom-in !important; }
    .ghiv-overlay {
      position: fixed;
      inset: 0;
      z-index: 2147483647;
      display: none;
      grid-template-rows: auto minmax(0, 1fr) auto auto;
      gap: 10px;
      box-sizing: border-box;
      padding: 16px;
      color: #f0f6fc;
      background: rgba(1, 4, 9, 0.82);
      backdrop-filter: blur(10px);
      font: 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    }
    .ghiv-overlay.ghiv-open { display: grid; }
    .ghiv-toolbar {
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 8px;
      min-width: 0;
    }
    .ghiv-counter {
      min-width: 88px;
      text-align: center;
      color: #c9d1d9;
      font-variant-numeric: tabular-nums;
    }
    .ghiv-nav-btn {
      position: fixed;
      top: 50%;
      z-index: 1;
      width: 56px;
      height: 72px;
      border: 1px solid rgba(240, 246, 252, 0.22);
      border-radius: 8px;
      padding: 0;
      display: grid;
      place-items: center;
      color: #f0f6fc;
      background: rgba(48, 54, 61, 0.78);
      font: 42px/1 Georgia, "Times New Roman", serif;
      cursor: pointer;
      transform: translateY(-50%);
      user-select: none;
    }
    .ghiv-nav-prev { left: 18px; }
    .ghiv-nav-next { right: 18px; }
    .ghiv-nav-btn:hover {
      border-color: rgba(88, 166, 255, 0.78);
      background: rgba(56, 139, 253, 0.28);
      color: #fff;
    }
    .ghiv-btn, .ghiv-link {
      height: 32px;
      min-width: 32px;
      border: 1px solid rgba(240, 246, 252, 0.22);
      border-radius: 6px;
      padding: 0 10px;
      color: #f0f6fc;
      background: rgba(48, 54, 61, 0.88);
      text-decoration: none;
      cursor: pointer;
      user-select: none;
    }
    .ghiv-btn:hover, .ghiv-link:hover {
      border-color: rgba(88, 166, 255, 0.7);
      background: rgba(56, 139, 253, 0.24);
      color: #fff;
      text-decoration: none;
    }
    .ghiv-stage {
      position: relative;
      min-width: 0;
      min-height: 0;
      overflow: hidden;
      display: grid;
      place-items: center;
      border-radius: 8px;
    }
    .ghiv-image {
      max-width: calc(100vw - 160px);
      max-height: calc(100vh - 190px);
      transform-origin: center center;
      transition: transform 90ms ease-out;
      cursor: grab;
      user-select: none;
      -webkit-user-drag: none;
      box-shadow: 0 18px 80px rgba(0, 0, 0, 0.45);
    }
    .ghiv-dragging .ghiv-image {
      cursor: grabbing;
      transition: none;
    }
    .ghiv-caption {
      min-height: 18px;
      max-width: min(100%, 980px);
      justify-self: center;
      color: #c9d1d9;
      text-align: center;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    .ghiv-strip {
      display: flex;
      gap: 8px;
      min-height: 54px;
      max-width: 100%;
      overflow-x: auto;
      padding: 2px 2px 8px;
      justify-self: center;
    }
    .ghiv-thumb {
      width: 56px;
      height: 42px;
      flex: 0 0 auto;
      border: 2px solid transparent;
      border-radius: 6px;
      padding: 0;
      background: rgba(48, 54, 61, 0.72);
      cursor: pointer;
      overflow: hidden;
    }
    .ghiv-thumb[aria-current="true"] { border-color: #58a6ff; }
    .ghiv-thumb img {
      width: 100%;
      height: 100%;
      display: block;
      object-fit: cover;
    }
    .ghiv-hint {
      justify-self: center;
      color: #8b949e;
      font-size: 12px;
      text-align: center;
    }
    @media (max-width: 720px) {
      .ghiv-overlay { padding: 10px; gap: 8px; }
      .ghiv-toolbar { justify-content: flex-start; overflow-x: auto; padding-bottom: 2px; }
      .ghiv-nav-btn { width: 44px; height: 58px; font-size: 34px; }
      .ghiv-nav-prev { left: 8px; }
      .ghiv-nav-next { right: 8px; }
      .ghiv-image { max-width: calc(100vw - 24px); max-height: calc(100vh - 184px); }
      .ghiv-caption { max-width: calc(100vw - 24px); }
    }
  `;

  addStyle(styleText);
  scheduleScan();
  observeGitHubNavigation();
  document.addEventListener('click', onDocumentClick, true);
  document.addEventListener('keydown', onKeyDown, true);

  function addStyle(css) {
    if (typeof GM_addStyle === 'function') {
      GM_addStyle(css);
      return;
    }

    const style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);
  }

  function observeGitHubNavigation() {
    ['turbo:load', 'turbo:render', 'pjax:end'].forEach((eventName) => {
      document.addEventListener(eventName, scheduleScan);
    });

    const observer = new MutationObserver((mutations) => {
      const hasPageChange = mutations.some((mutation) => {
        if (isViewerNode(mutation.target)) return false;
        return Array.from(mutation.addedNodes).some((node) => !isViewerNode(node));
      });

      if (hasPageChange) {
        scheduleScan();
      }
    });

    observer.observe(document.documentElement, { childList: true, subtree: true });
  }

  function scheduleScan() {
    window.clearTimeout(scanTimer);
    scanTimer = window.setTimeout(scanImages, 120);
  }

  function scanImages() {
    imageItems = collectImages();
    imageItems.forEach((item) => {
      item.img.classList.add('ghiv-ready');
      if (!item.img.title) {
        item.img.title = '点击预览图片';
      }
    });

    if (viewer && viewer.root.classList.contains('ghiv-open')) {
      renderThumbs();
      updateCounter();
    }
  }

  function collectImages() {
    const nodes = new Set();

    ROOT_SELECTORS.forEach((selector) => {
      document.querySelectorAll(`${selector} img`).forEach((img) => nodes.add(img));
    });

    return Array.from(nodes)
      .filter(isPreviewableImage)
      .map((img) => {
        const src = normalizeUrl(img.currentSrc || img.src || img.getAttribute('src') || img.dataset.canonicalSrc || '');
        const original = normalizeUrl(img.dataset.canonicalSrc || src);
        return {
          img,
          src,
          original,
          alt: (img.getAttribute('alt') || img.getAttribute('aria-label') || '').trim()
        };
      })
      .filter((item) => item.src);
  }

  function isPreviewableImage(img) {
    if (!(img instanceof HTMLImageElement)) return false;
    if (!isInsideMarkdown(img)) return false;
    if (img.closest('g-emoji, .emoji, .avatar, .octicon, .reaction-summary-item')) return false;

    const src = img.currentSrc || img.src || img.getAttribute('src') || img.dataset.canonicalSrc || '';
    if (!src || src.startsWith('data:')) return false;

    const rect = img.getBoundingClientRect();
    const width = img.naturalWidth || img.width || rect.width;
    const height = img.naturalHeight || img.height || rect.height;

    if (width < PREVIEW_MIN_DIMENSION || height < PREVIEW_MIN_DIMENSION) return false;
    if (rect.width === 0 && rect.height === 0) return false;
    if (isBadgeImage(img, src, width, height)) return false;

    return true;
  }

  function isInsideMarkdown(element) {
    return ROOT_SELECTORS.some((selector) => Boolean(element.closest(selector)));
  }

  function normalizeUrl(value) {
    if (!value) return '';
    try {
      return new URL(value, location.href).href;
    } catch (_) {
      return value;
    }
  }

  function isBadgeImage(img, src, width, height) {
    const link = img.closest('a');
    const href = link ? normalizeUrl(link.getAttribute('href') || '') : '';
    const alt = img.getAttribute('alt') || '';
    const title = img.getAttribute('title') || '';
    const text = `${src} ${href} ${alt} ${title}`;
    const badgeShape = width <= 260 && height <= 42;

    try {
      const url = new URL(src, location.href);
      const host = url.hostname.toLowerCase();
      const path = url.pathname.toLowerCase();

      if (BADGE_HOSTS.some((badgeHost) => host === badgeHost || host.endsWith(`.${badgeHost}`))) {
        return true;
      }

      if (/\/actions\/workflows\/[^/]+\/badge\.svg$/i.test(path)) {
        return true;
      }

      if (path.endsWith('/badge.svg') && badgeShape) {
        return true;
      }
    } catch (_) {
      // Fall through to the text and shape heuristic below.
    }

    return badgeShape && BADGE_TEXT_PATTERN.test(text);
  }
  function isViewerNode(node) {
    if (!viewer || !node) return false;
    if (node === viewer.root) return true;
    if (node instanceof Node && viewer.root.contains(node)) return true;
    return false;
  }

  function onDocumentClick(event) {
    const target = event.target instanceof Element ? event.target : null;
    const img = target ? target.closest('img') : null;

    if (!img || !isInsideMarkdown(img)) return;
    if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;

    scanImages();
    const index = imageItems.findIndex((item) => item.img === img);
    if (index < 0) return;

    event.preventDefault();
    event.stopPropagation();
    event.stopImmediatePropagation();
    openViewer(index);
  }

  function ensureViewer() {
    if (viewer) return viewer;

    const root = document.createElement('div');
    root.className = 'ghiv-overlay';
    root.setAttribute('role', 'dialog');
    root.setAttribute('aria-modal', 'true');
    root.setAttribute('aria-label', 'GitHub 图片预览');
    root.tabIndex = -1;

    root.innerHTML = `
      <div class="ghiv-toolbar">
        <div class="ghiv-counter">0 / 0</div>
        <button class="ghiv-btn" type="button" data-action="zoom-out" aria-label="缩小">−</button>
        <button class="ghiv-btn" type="button" data-action="fit" aria-label="适应窗口">适应</button>
        <button class="ghiv-btn" type="button" data-action="zoom-in" aria-label="放大">+</button>
        <a class="ghiv-link" target="_blank" rel="noopener noreferrer" data-role="open-original">原图</a>
        <button class="ghiv-btn" type="button" data-action="close" aria-label="关闭">×</button>
      </div>
      <button class="ghiv-nav-btn ghiv-nav-prev" type="button" data-action="prev" aria-label="上一张">‹</button>
      <button class="ghiv-nav-btn ghiv-nav-next" type="button" data-action="next" aria-label="下一张">›</button>
      <div class="ghiv-stage" data-role="stage">
        <img class="ghiv-image" alt="" draggable="false" data-role="image">
      </div>
      <div class="ghiv-caption" data-role="caption"></div>
      <div class="ghiv-strip" data-role="strip" aria-label="当前页面图片列表"></div>
      <div class="ghiv-hint">滚轮缩放,拖动平移,←/→ 切换,Esc 关闭,双击还原</div>
    `;

    document.body.appendChild(root);

    viewer = {
      root,
      stage: root.querySelector('[data-role="stage"]'),
      image: root.querySelector('[data-role="image"]'),
      caption: root.querySelector('[data-role="caption"]'),
      counter: root.querySelector('.ghiv-counter'),
      strip: root.querySelector('[data-role="strip"]'),
      originalLink: root.querySelector('[data-role="open-original"]')
    };

    root.addEventListener('click', onViewerClick);
    viewer.stage.addEventListener('wheel', onWheel, { passive: false });
    viewer.image.addEventListener('pointerdown', onPointerDown);
    viewer.image.addEventListener('dblclick', resetTransform);
    window.addEventListener('pointermove', onPointerMove);
    window.addEventListener('pointerup', onPointerUp);
    window.addEventListener('resize', resetTransform);

    return viewer;
  }

  function openViewer(index) {
    const ui = ensureViewer();
    previousBodyOverflow = document.body.style.overflow;
    document.body.style.overflow = 'hidden';
    ui.root.classList.add('ghiv-open');
    ui.root.focus({ preventScroll: true });
    renderThumbs();
    showImage(index);
  }

  function closeViewer() {
    if (!viewer || !viewer.root.classList.contains('ghiv-open')) return;
    viewer.root.classList.remove('ghiv-open', 'ghiv-dragging');
    document.body.style.overflow = previousBodyOverflow;
    state.dragging = false;
  }

  function showImage(index) {
    if (!imageItems.length || !viewer) return;

    currentIndex = (index + imageItems.length) % imageItems.length;
    const item = imageItems[currentIndex];

    viewer.image.src = item.src;
    viewer.image.alt = item.alt || 'GitHub 图片预览';
    viewer.caption.textContent = item.alt || item.original || item.src;
    viewer.originalLink.href = item.original || item.src;

    resetTransform();
    updateCounter();
    syncActiveThumb();
  }

  function renderThumbs() {
    if (!viewer) return;

    viewer.strip.textContent = '';
    imageItems.forEach((item, index) => {
      const button = document.createElement('button');
      button.type = 'button';
      button.className = 'ghiv-thumb';
      button.dataset.index = String(index);
      button.setAttribute('aria-label', `查看第 ${index + 1} 张图片`);

      const thumb = document.createElement('img');
      thumb.src = item.src;
      thumb.alt = item.alt || '';
      thumb.loading = 'lazy';

      button.appendChild(thumb);
      viewer.strip.appendChild(button);
    });

    syncActiveThumb();
  }

  function syncActiveThumb() {
    if (!viewer) return;

    viewer.strip.querySelectorAll('.ghiv-thumb').forEach((button) => {
      const active = Number(button.dataset.index) === currentIndex;
      button.setAttribute('aria-current', active ? 'true' : 'false');
      if (active) button.scrollIntoView({ block: 'nearest', inline: 'center' });
    });
  }

  function updateCounter() {
    if (!viewer) return;
    viewer.counter.textContent = `${currentIndex + 1} / ${imageItems.length}`;
  }

  function onViewerClick(event) {
    const target = event.target instanceof Element ? event.target : null;
    if (!target || !viewer) return;

    const actionButton = target.closest('[data-action]');
    if (actionButton) {
      const action = actionButton.dataset.action;
      if (action === 'prev') showImage(currentIndex - 1);
      if (action === 'next') showImage(currentIndex + 1);
      if (action === 'zoom-in') zoomBy(ZOOM_STEP);
      if (action === 'zoom-out') zoomBy(1 / ZOOM_STEP);
      if (action === 'fit') resetTransform();
      if (action === 'close') closeViewer();
      return;
    }

    const thumbButton = target.closest('.ghiv-thumb');
    if (thumbButton) {
      showImage(Number(thumbButton.dataset.index));
      return;
    }

    if (target === viewer.root || target === viewer.stage) {
      closeViewer();
    }
  }

  function onKeyDown(event) {
    if (!viewer || !viewer.root.classList.contains('ghiv-open')) return;

    if (event.key === 'Escape') {
      event.preventDefault();
      closeViewer();
    } else if (event.key === 'ArrowLeft') {
      event.preventDefault();
      showImage(currentIndex - 1);
    } else if (event.key === 'ArrowRight') {
      event.preventDefault();
      showImage(currentIndex + 1);
    } else if (event.key === '+' || event.key === '=') {
      event.preventDefault();
      zoomBy(ZOOM_STEP);
    } else if (event.key === '-' || event.key === '_') {
      event.preventDefault();
      zoomBy(1 / ZOOM_STEP);
    } else if (event.key === '0') {
      event.preventDefault();
      resetTransform();
    }
  }

  function onWheel(event) {
    if (!viewer || !viewer.root.classList.contains('ghiv-open')) return;

    event.preventDefault();
    const factor = event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
    zoomBy(factor);
  }

  function zoomBy(factor) {
    state.scale = clamp(state.scale * factor, MIN_SCALE, MAX_SCALE);
    applyTransform();
  }

  function resetTransform() {
    state.scale = 1;
    state.x = 0;
    state.y = 0;
    applyTransform();
  }

  function applyTransform() {
    if (!viewer) return;
    viewer.image.style.transform = `translate3d(${state.x}px, ${state.y}px, 0) scale(${state.scale})`;
  }

  function onPointerDown(event) {
    if (!viewer || event.button !== 0) return;

    state.dragging = true;
    state.dragStartX = event.clientX;
    state.dragStartY = event.clientY;
    state.startX = state.x;
    state.startY = state.y;
    viewer.root.classList.add('ghiv-dragging');
    viewer.image.setPointerCapture?.(event.pointerId);
  }

  function onPointerMove(event) {
    if (!state.dragging || !viewer) return;

    state.x = state.startX + event.clientX - state.dragStartX;
    state.y = state.startY + event.clientY - state.dragStartY;
    applyTransform();
  }

  function onPointerUp(event) {
    if (!state.dragging || !viewer) return;

    state.dragging = false;
    viewer.root.classList.remove('ghiv-dragging');
    viewer.image.releasePointerCapture?.(event.pointerId);
  }

  function clamp(value, min, max) {
    return Math.min(max, Math.max(min, value));
  }
})();