GitHub README Image Viewer

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

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

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