Open GitHub markdown images in a lightbox instead of navigating away
// ==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
);
})();