Created
August 19, 2026 20:02
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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">‹</button> | |
| <button class="ghlb-nav ghlb-next" aria-label="Next image">›</button> | |
| <button class="ghlb-close" aria-label="Close">✕</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