Skip to content

Instantly share code, notes, and snippets.

@corgan2222
Created August 19, 2026 20:02
Show Gist options
  • Select an option

  • Save corgan2222/3110ce74ac1e8fe19025c5f417010f2e to your computer and use it in GitHub Desktop.

Select an option

Save corgan2222/3110ce74ac1e8fe19025c5f417010f2e to your computer and use it in GitHub Desktop.
Tampermonkey userscript that opens images in GitHub READMEs, issues and PRs in a lightbox instead of navigating away — gallery navigation, zoom, pan.
// ==UserScript==
// @name GitHub Image Lightbox
// @namespace https://github.com/corgan2222
// @version 1.0.0
// @description Opens images from READMEs, issues, PRs and discussions in a lightbox instead of navigating to a new page. Gallery navigation, zoom, pan, ESC to close.
// @author corgan2222
// @match https://github.com/*
// @match https://gist.github.com/*
// @run-at document-idle
// @grant none
// @license MIT
// ==/UserScript==
(() => {
'use strict';
// --------------------------------------------------------------- Detection
const IMG_EXT = /\.(png|jpe?g|gif|webp|avif|svg|bmp|tiff?|ico)(?:[?#].*)?$/i;
/**
* Checks whether a link actually points at an image.
* This matters because badges (shields.io, Discord, CI) are also <a><img>,
* but they point at a page and must keep navigating normally.
*/
function isImageTarget(a) {
if (!a || !a.href) return false;
let u;
try { u = new URL(a.href, location.href); } catch { return false; }
// GitHub proxies external images through camo, so the URL has no extension
if (u.hostname === 'camo.githubusercontent.com') return true;
// raw / user-images / private-user-images
if (/(^|\.)githubusercontent\.com$/i.test(u.hostname)) return true;
// newer upload path used by the web editor
if (u.hostname === 'github.com' && u.pathname.startsWith('/user-attachments/assets/')) return true;
return IMG_EXT.test(u.pathname);
}
/** Rewrite a blob/raw page URL into a direct image URL */
function toDirectUrl(href) {
let u;
try { u = new URL(href, location.href); } catch { return href; }
if (u.hostname === 'github.com') {
const m = u.pathname.match(/^\/([^/]+)\/([^/]+)\/(?:blob|raw)\/(.+)$/);
if (m) return `https://raw.githubusercontent.com/${m[1]}/${m[2]}/${m[3]}`;
}
return u.href;
}
/** Collect every lightbox-capable image inside the same markdown block */
function collectGallery(img) {
const root = img.closest('.markdown-body') || document;
return [...root.querySelectorAll('a > img')]
.filter(el => isImageTarget(el.parentElement));
}
function sourceFor(img) {
const a = img.parentElement;
return isImageTarget(a) ? toDirectUrl(a.href) : (img.currentSrc || img.src);
}
// ------------------------------------------------------------------- Style
const style = document.createElement('style');
style.textContent = `
.ghlb::backdrop { background: rgba(1, 4, 9, .88); }
.ghlb {
max-width: 100vw; max-height: 100vh;
width: 100vw; height: 100vh;
padding: 0; border: 0; overflow: hidden;
background: transparent;
color: #e6edf3;
font: 12px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
}
.ghlb-stage {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
overflow: hidden; touch-action: none;
}
.ghlb-img {
max-width: 92vw; max-height: 86vh;
display: block;
transform-origin: center center;
cursor: zoom-in;
user-select: none; -webkit-user-drag: none;
transition: transform .12s ease-out;
}
.ghlb-img.is-zoomed { cursor: grab; }
.ghlb-img.is-panning { cursor: grabbing; transition: none; }
.ghlb-bar {
position: absolute; left: 0; right: 0; bottom: 0;
display: flex; align-items: center; gap: 16px;
padding: 10px 16px;
background: linear-gradient(transparent, rgba(1, 4, 9, .75) 55%);
pointer-events: none;
}
.ghlb-caption {
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
opacity: .85;
}
.ghlb-count { opacity: .6; font-variant-numeric: tabular-nums; }
.ghlb-open {
color: #e6edf3; opacity: .6; text-decoration: none;
pointer-events: auto; border-bottom: 1px solid currentColor;
}
.ghlb-open:hover { opacity: 1; }
.ghlb-nav {
position: absolute; top: 50%; transform: translateY(-50%);
width: 44px; height: 88px;
display: grid; place-items: center;
border: 0; border-radius: 6px;
background: rgba(1, 4, 9, .45); color: #e6edf3;
font-size: 22px; line-height: 1; cursor: pointer;
opacity: 0; transition: opacity .15s;
}
.ghlb:hover .ghlb-nav { opacity: .75; }
.ghlb-nav:hover { opacity: 1 !important; background: rgba(1, 4, 9, .7); }
.ghlb-nav:focus-visible { opacity: 1; outline: 2px solid #2f81f7; }
.ghlb-prev { left: 12px; }
.ghlb-next { right: 12px; }
.ghlb-close {
position: absolute; top: 12px; right: 12px;
width: 34px; height: 34px;
border: 0; border-radius: 6px;
background: rgba(1, 4, 9, .45); color: #e6edf3;
font-size: 18px; line-height: 1; cursor: pointer; opacity: .7;
}
.ghlb-close:hover { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.ghlb-img, .ghlb-nav { transition: none; }
}
`;
document.head.append(style);
// ---------------------------------------------------------------- Lightbox
let dlg = null;
function open(gallery, index) {
let i = index;
let scale = 1, tx = 0, ty = 0;
dlg = document.createElement('dialog');
dlg.className = 'ghlb';
dlg.innerHTML = `
<div class="ghlb-stage"><img class="ghlb-img" alt=""></div>
<button class="ghlb-nav ghlb-prev" aria-label="Previous image">&#8249;</button>
<button class="ghlb-nav ghlb-next" aria-label="Next image">&#8250;</button>
<button class="ghlb-close" aria-label="Close">&#10005;</button>
<div class="ghlb-bar">
<span class="ghlb-caption"></span>
<span class="ghlb-count"></span>
<a class="ghlb-open" target="_blank" rel="noopener">Original</a>
</div>`;
const stage = dlg.querySelector('.ghlb-stage');
const el = dlg.querySelector('.ghlb-img');
const caption = dlg.querySelector('.ghlb-caption');
const count = dlg.querySelector('.ghlb-count');
const link = dlg.querySelector('.ghlb-open');
const single = gallery.length < 2;
dlg.querySelector('.ghlb-prev').hidden = single;
dlg.querySelector('.ghlb-next').hidden = single;
function applyTransform() {
el.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`;
el.classList.toggle('is-zoomed', scale > 1);
}
function show(next) {
i = (next + gallery.length) % gallery.length;
const src = sourceFor(gallery[i]);
scale = 1; tx = 0; ty = 0;
applyTransform();
el.src = src;
el.alt = gallery[i].alt || '';
caption.textContent = gallery[i].alt || '';
count.textContent = single ? '' : `${i + 1} / ${gallery.length}`;
link.href = src;
// Preload the neighbouring images
if (!single) {
for (const n of [i + 1, i - 1]) {
new Image().src = sourceFor(gallery[(n + gallery.length) % gallery.length]);
}
}
}
function zoomAt(factor, cx, cy) {
const prev = scale;
scale = Math.min(8, Math.max(1, scale * factor));
if (scale === 1) { tx = 0; ty = 0; applyTransform(); return; }
const r = el.getBoundingClientRect();
const ox = cx - (r.left + r.width / 2);
const oy = cy - (r.top + r.height / 2);
const k = scale / prev;
tx = tx - ox * (k - 1);
ty = ty - oy * (k - 1);
applyTransform();
}
// Click on the image toggles zoom, click beside it closes the lightbox
el.addEventListener('click', e => {
e.stopPropagation();
if (scale > 1) { scale = 1; tx = 0; ty = 0; applyTransform(); }
else zoomAt(2.5, e.clientX, e.clientY);
});
stage.addEventListener('click', () => dlg.close());
stage.addEventListener('wheel', e => {
e.preventDefault();
zoomAt(e.deltaY < 0 ? 1.2 : 1 / 1.2, e.clientX, e.clientY);
}, { passive: false });
let drag = null;
el.addEventListener('pointerdown', e => {
if (scale === 1) return;
drag = { x: e.clientX - tx, y: e.clientY - ty };
el.setPointerCapture(e.pointerId);
el.classList.add('is-panning');
});
el.addEventListener('pointermove', e => {
if (!drag) return;
tx = e.clientX - drag.x;
ty = e.clientY - drag.y;
applyTransform();
});
el.addEventListener('pointerup', () => { drag = null; el.classList.remove('is-panning'); });
dlg.querySelector('.ghlb-prev').addEventListener('click', e => { e.stopPropagation(); show(i - 1); });
dlg.querySelector('.ghlb-next').addEventListener('click', e => { e.stopPropagation(); show(i + 1); });
dlg.querySelector('.ghlb-close').addEventListener('click', () => dlg.close());
// Swallow key events so GitHub's own hotkeys stay inert while the lightbox is open
dlg.addEventListener('keydown', e => {
e.stopPropagation();
if (e.key === 'ArrowLeft') { e.preventDefault(); show(i - 1); }
if (e.key === 'ArrowRight') { e.preventDefault(); show(i + 1); }
});
dlg.addEventListener('close', () => { dlg.remove(); dlg = null; });
document.body.append(dlg);
show(i);
dlg.showModal();
}
// ------------------------------------------------------------------- Hook
document.addEventListener('click', e => {
// Modifier or middle click: keep the default behaviour (open in a new tab)
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
if (dlg) return;
const img = e.target.closest('img');
if (!img) return;
const a = img.parentElement;
if (!a || a.tagName !== 'A' || !isImageTarget(a)) return;
e.preventDefault();
e.stopPropagation();
const gallery = collectGallery(img);
open(gallery.length ? gallery : [img], Math.max(0, gallery.indexOf(img)));
}, true);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment