GitHub README Image Viewer

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

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

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

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

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

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

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

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

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

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

이 스타일을 설치하려면 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));
  }
})();