GitHub Image Lightbox

Open GitHub markdown images in a lightbox instead of navigating away

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         GitHub Image Lightbox
// @namespace    http://tampermonkey.net/
// @version      1.0
// @license      MIT
// @description  Open GitHub markdown images in a lightbox instead of navigating away
// @author       You
// @match        https://github.com/*
// @grant        none
// ==/UserScript==

(function () {
  var CONTENT_SEL = ".markdown-body, .js-comment-body, .comment-body";
  var STEP = 0.1;
  var MIN = 0.2;
  var MAX = 5;
  var Z = 2147483000;
  var state = null;

  function absUrl(url) {
    try {
      return new URL(url, location.href).href;
    } catch (err) {
      return url || "";
    }
  }

  // github.com/owner/repo/blob/ref/path.png -> .../raw/ref/path.png
  function toDisplayUrl(url) {
    url = absUrl(url);
    return url.replace(
      /^(https?:\/\/github\.com\/[^/]+\/[^/]+)\/blob\/([^?#]+)/i,
      "$1/raw/$2"
    );
  }

  function imageUrlFrom(img) {
    // Prefer the actually loaded image URL. Parent <a> on GitHub is often a /blob/ HTML page.
    var src = img.currentSrc || img.src || "";
    if (src) return toDisplayUrl(src);

    var a = img.closest("a[href]");
    if (a && a.href) return toDisplayUrl(a.href);
    return "";
  }

  function isContentImg(el) {
    // tagName, not instanceof — safer across userscript injection worlds
    return !!(
      el &&
      el.tagName === "IMG" &&
      el.closest(CONTENT_SEL) &&
      !el.closest("#gh-img-lightbox")
    );
  }

  function findContentImg(e) {
    var path = typeof e.composedPath === "function" ? e.composedPath() : [];
    var i;
    var n;
    for (i = 0; i < path.length; i++) {
      n = path[i];
      if (isContentImg(n)) return n;
    }

    var t = e.target;
    if (!t || !t.closest) return null;

    if (isContentImg(t)) return t;
    n = t.closest("img");
    if (isContentImg(n)) return n;

    // click hit the wrapping <a> rather than the img itself
    var a = t.closest("a");
    if (a && a.closest(CONTENT_SEL)) {
      var imgs = a.querySelectorAll("img");
      if (imgs.length === 1 && isContentImg(imgs[0])) return imgs[0];
    }
    return null;
  }

  function clamp(n, lo, hi) {
    return Math.min(hi, Math.max(lo, n));
  }

  function applyTransform() {
    if (!state) return;
    state.img.style.transform =
      "translate(" + state.tx + "px, " + state.ty + "px) scale(" + state.scale + ")";
  }

  function setScale(next) {
    if (!state) return;
    state.scale = clamp(Math.round(next * 100) / 100, MIN, MAX);
    applyTransform();
  }

  function close() {
    if (!state) return;
    if (state.overlay && state.overlay.parentNode) state.overlay.parentNode.removeChild(state.overlay);
    if (state.closeBar && state.closeBar.parentNode) state.closeBar.parentNode.removeChild(state.closeBar);
    if (state.zoomBar && state.zoomBar.parentNode) state.zoomBar.parentNode.removeChild(state.zoomBar);
    state = null;
    document.removeEventListener("keydown", onKey, true);
  }

  function onKey(e) {
    if (e.key === "Escape") {
      e.preventDefault();
      e.stopPropagation();
      close();
    }
  }

  function fitScale(img) {
    var mw = window.innerWidth * 0.92;
    var mh = window.innerHeight * 0.86;
    var w = img.naturalWidth || img.width || 1;
    var h = img.naturalHeight || img.height || 1;
    return clamp(Math.min(mw / w, mh / h, 1), MIN, MAX);
  }

  function btn(label, title, onClick) {
    var b = document.createElement("button");
    b.type = "button";
    b.textContent = label;
    b.title = title;
    b.style.cssText =
      "margin:0 4px;padding:6px 12px;border:1px solid rgba(255,255,255,.35);" +
      "border-radius:6px;background:rgba(0,0,0,.55);color:#fff;" +
      "font:14px/1.2 system-ui,sans-serif;cursor:pointer;";
    b.addEventListener("click", function (e) {
      e.preventDefault();
      e.stopPropagation();
      onClick();
    });
    return b;
  }

  function open(url) {
    close();

    var overlay = document.createElement("div");
    overlay.id = "gh-img-lightbox";
    overlay.style.cssText =
      "position:fixed;inset:0;z-index:" +
      Z +
      ";background:rgba(0,0,0,.82);display:flex;" +
      "align-items:center;justify-content:center;cursor:zoom-out;";

    // Bars hang off documentElement so GitHub ancestor transforms can't trap position:fixed
    var closeBar = document.createElement("div");
    closeBar.id = "gh-img-lightbox-close";
    closeBar.style.cssText =
      "position:fixed;top:16px;right:16px;z-index:" +
      (Z + 2) +
      ";display:flex;pointer-events:auto;";

    var zoomBar = document.createElement("div");
    zoomBar.id = "gh-img-lightbox-zoom";
    zoomBar.style.cssText =
      "position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:" +
      (Z + 2) +
      ";display:flex;pointer-events:auto;";

    var img = document.createElement("img");
    img.alt = "";
    img.draggable = false;
    img.style.cssText =
      "max-width:none;max-height:none;transform-origin:center center;" +
      "cursor:grab;user-select:none;box-shadow:0 8px 40px rgba(0,0,0,.5);";

    state = {
      overlay: overlay,
      closeBar: closeBar,
      zoomBar: zoomBar,
      img: img,
      scale: 1,
      tx: 0,
      ty: 0,
      fit: 1,
      dragging: false,
      moved: false,
      lx: 0,
      ly: 0,
    };

    closeBar.appendChild(btn("×", "Close", close));
    zoomBar.appendChild(btn("−", "Zoom out", function () { setScale(state.scale - STEP); }));
    zoomBar.appendChild(btn("+", "Zoom in", function () { setScale(state.scale + STEP); }));

    overlay.appendChild(img);
    document.documentElement.appendChild(overlay);
    document.documentElement.appendChild(closeBar);
    document.documentElement.appendChild(zoomBar);
    document.addEventListener("keydown", onKey, true);

    overlay.addEventListener("click", function (e) {
      if (e.target === overlay) close();
    });

    overlay.addEventListener(
      "wheel",
      function (e) {
        e.preventDefault();
        if (!state) return;
        setScale(state.scale + (e.deltaY > 0 ? -STEP : STEP));
      },
      { passive: false }
    );

    img.addEventListener("pointerdown", function (e) {
      if (!state || e.button !== 0) return;
      e.preventDefault();
      e.stopPropagation();
      state.dragging = true;
      state.moved = false;
      state.lx = e.clientX;
      state.ly = e.clientY;
      img.setPointerCapture(e.pointerId);
      img.style.cursor = "grabbing";
    });

    img.addEventListener("pointermove", function (e) {
      if (!state || !state.dragging) return;
      var dx = e.clientX - state.lx;
      var dy = e.clientY - state.ly;
      if (Math.abs(dx) > 2 || Math.abs(dy) > 2) state.moved = true;
      state.tx += dx;
      state.ty += dy;
      state.lx = e.clientX;
      state.ly = e.clientY;
      applyTransform();
    });

    function endDrag(e) {
      if (!state || !state.dragging) return;
      state.dragging = false;
      img.style.cursor = "grab";
      try {
        img.releasePointerCapture(e.pointerId);
      } catch (err) {
        // already released
      }
    }
    img.addEventListener("pointerup", endDrag);
    img.addEventListener("pointercancel", endDrag);

    img.addEventListener("click", function (e) {
      e.preventDefault();
      e.stopPropagation();
    });

    img.addEventListener("load", function () {
      if (!state || state.img !== img) return;
      state.fit = fitScale(img);
      state.scale = state.fit;
      state.tx = 0;
      state.ty = 0;
      applyTransform();
    });

    img.src = url;
  }

  document.addEventListener(
    "click",
    function (e) {
      if (e.defaultPrevented || e.button !== 0) return;
      if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;

      var img = findContentImg(e);
      if (!img) return;

      var url = imageUrlFrom(img);
      if (!url) return;

      e.preventDefault();
      e.stopPropagation();
      e.stopImmediatePropagation();
      open(url);
    },
    true
  );
})();