Last active
September 9, 2026 13:59
-
-
Save rafaelsteil/3f6a569c68b2e4156796e7b3659bd0bc to your computer and use it in GitHub Desktop.
TamperMonkey script to open GitHub README and markdown images in a lightbox instead of the blob page
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://gist.github.com/rafaelsteil/3f6a569c68b2e4156796e7b3659bd0bc | |
| // @version 1.0.0 | |
| // @description Open GitHub README and markdown images in a lightbox instead of the blob page | |
| // @author Rafael Steil | |
| // @match https://github.com/* | |
| // @match https://gist.github.com/* | |
| // @run-at document-end | |
| // @noframes | |
| // @grant none | |
| // ==/UserScript== | |
| (function () { | |
| "use strict"; | |
| const ROOT_ID = "gilb-root"; | |
| if (document.getElementById(ROOT_ID)) return; | |
| const IMAGE_EXT = /\.(?:png|jpe?g|gif|webp|svg|bmp|ico|avif|apng|tif|tiff)(?:$|[?#])/i; | |
| const GITHUB_ASSET = /(?:user-images\.githubusercontent\.com|private-user-images\.githubusercontent\.com|media\.githubusercontent\.com|\/user-attachments\/assets\/|github\.com\/[^/]+\/[^/]+\/assets\/)/i; | |
| const MARKDOWN_IMAGE = ".markdown-body img"; | |
| let activeIndex = -1; | |
| let gallery = []; | |
| let lastFocus = null; | |
| const style = document.createElement("style"); | |
| style.textContent = ` | |
| #${ROOT_ID} { | |
| position: fixed; | |
| inset: 0; | |
| z-index: 2147483646; | |
| display: flex; | |
| flex-direction: column; | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif; | |
| color: #f0f6fc; | |
| } | |
| #${ROOT_ID}[hidden] { display: none !important; } | |
| #${ROOT_ID} .gilb-backdrop { | |
| position: absolute; | |
| inset: 0; | |
| background: rgba(1, 4, 9, 0.82); | |
| backdrop-filter: blur(6px); | |
| } | |
| #${ROOT_ID} .gilb-chrome { | |
| position: relative; | |
| z-index: 1; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 16px; | |
| padding: 16px 20px; | |
| pointer-events: none; | |
| } | |
| #${ROOT_ID} .gilb-actions { | |
| display: flex; | |
| gap: 10px; | |
| pointer-events: auto; | |
| } | |
| #${ROOT_ID} .gilb-caption { | |
| min-width: 0; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| font-size: 13px; | |
| color: #9198a1; | |
| pointer-events: none; | |
| } | |
| #${ROOT_ID} a.gilb-btn, | |
| #${ROOT_ID} button.gilb-btn { | |
| appearance: none; | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| padding: 6px 12px; | |
| border: 1px solid #3d444d; | |
| border-radius: 6px; | |
| background: #212830; | |
| color: #f0f6fc; | |
| font: inherit; | |
| font-size: 13px; | |
| font-weight: 600; | |
| line-height: 20px; | |
| text-decoration: none; | |
| cursor: pointer; | |
| } | |
| #${ROOT_ID} a.gilb-btn:hover, | |
| #${ROOT_ID} button.gilb-btn:hover { | |
| background: #262c36; | |
| border-color: #656c76; | |
| } | |
| #${ROOT_ID} a.gilb-btn:focus-visible, | |
| #${ROOT_ID} button.gilb-btn:focus-visible { | |
| outline: 2px solid #1f6feb; | |
| outline-offset: 2px; | |
| } | |
| #${ROOT_ID} .gilb-stage { | |
| position: relative; | |
| z-index: 1; | |
| flex: 1; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| min-height: 0; | |
| padding: 0 72px 24px; | |
| } | |
| #${ROOT_ID} .gilb-image { | |
| max-width: 100%; | |
| max-height: 100%; | |
| width: auto; | |
| height: auto; | |
| object-fit: contain; | |
| border-radius: 8px; | |
| box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45); | |
| background: #0d1117; | |
| } | |
| #${ROOT_ID} .gilb-nav { | |
| position: absolute; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| width: 40px; | |
| height: 40px; | |
| border: 1px solid #3d444d; | |
| border-radius: 50%; | |
| background: #212830; | |
| color: #f0f6fc; | |
| font-size: 22px; | |
| line-height: 1; | |
| cursor: pointer; | |
| } | |
| #${ROOT_ID} .gilb-nav:hover { background: #262c36; } | |
| #${ROOT_ID} .gilb-nav[hidden] { display: none; } | |
| #${ROOT_ID} .gilb-prev { left: 16px; } | |
| #${ROOT_ID} .gilb-next { right: 16px; } | |
| #${ROOT_ID} .gilb-error { | |
| color: #ffa198; | |
| font-size: 14px; | |
| } | |
| body.gilb-lock { overflow: hidden !important; } | |
| .markdown-body img.gilb-zoom { cursor: zoom-in; } | |
| `; | |
| document.documentElement.appendChild(style); | |
| const root = document.createElement("div"); | |
| root.id = ROOT_ID; | |
| root.hidden = true; | |
| root.setAttribute("role", "dialog"); | |
| root.setAttribute("aria-modal", "true"); | |
| root.setAttribute("aria-label", "Image preview"); | |
| root.innerHTML = ` | |
| <div class="gilb-backdrop" data-gilb-close></div> | |
| <div class="gilb-chrome"> | |
| <div class="gilb-actions"> | |
| <button type="button" class="gilb-btn gilb-close" data-gilb-close>Close</button> | |
| </div> | |
| <div class="gilb-caption" aria-live="polite"></div> | |
| <div class="gilb-actions"> | |
| <a class="gilb-btn gilb-open" href="#" target="_blank" rel="noopener noreferrer">Open original</a> | |
| </div> | |
| </div> | |
| <div class="gilb-stage"> | |
| <button type="button" class="gilb-nav gilb-prev" aria-label="Previous image">‹</button> | |
| <img class="gilb-image" alt=""> | |
| <div class="gilb-error" hidden>Could not load this image.</div> | |
| <button type="button" class="gilb-nav gilb-next" aria-label="Next image">›</button> | |
| </div> | |
| `; | |
| document.documentElement.appendChild(root); | |
| const els = { | |
| image: root.querySelector(".gilb-image"), | |
| error: root.querySelector(".gilb-error"), | |
| caption: root.querySelector(".gilb-caption"), | |
| open: root.querySelector(".gilb-open"), | |
| prev: root.querySelector(".gilb-prev"), | |
| next: root.querySelector(".gilb-next"), | |
| close: root.querySelector(".gilb-close"), | |
| }; | |
| function isModifiedClick(event) { | |
| return event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey; | |
| } | |
| function isImageUrl(url) { | |
| if (!url) return false; | |
| try { | |
| const parsed = new URL(url, location.origin); | |
| return IMAGE_EXT.test(parsed.pathname) || GITHUB_ASSET.test(parsed.href); | |
| } catch { | |
| return IMAGE_EXT.test(url) || GITHUB_ASSET.test(url); | |
| } | |
| } | |
| function shouldLightbox(img, link) { | |
| if (!img || img.closest("#" + ROOT_ID)) return false; | |
| if (!img.closest(".markdown-body")) return false; | |
| if (!link) return isImageUrl(img.currentSrc || img.src); | |
| // Only intercept navigations that would open an image (blob/raw/asset/file), | |
| // not badges or images that wrap a regular page link. | |
| return isImageUrl(link.href); | |
| } | |
| function collectGallery() { | |
| return Array.from(document.querySelectorAll(MARKDOWN_IMAGE)).filter((img) => { | |
| const link = img.closest("a"); | |
| return shouldLightbox(img, link) && (img.currentSrc || img.src); | |
| }); | |
| } | |
| function originalUrlFor(img) { | |
| const link = img.closest("a"); | |
| if (link && link.href) return link.href; | |
| return img.currentSrc || img.src; | |
| } | |
| function show(index) { | |
| const img = gallery[index]; | |
| if (!img) return; | |
| activeIndex = index; | |
| const src = img.currentSrc || img.src; | |
| const original = originalUrlFor(img); | |
| els.image.hidden = false; | |
| els.error.hidden = true; | |
| els.image.src = src; | |
| els.image.alt = img.alt || ""; | |
| els.open.href = original; | |
| els.caption.textContent = img.alt || original.replace(/^https?:\/\/github\.com/, ""); | |
| els.prev.hidden = gallery.length < 2; | |
| els.next.hidden = gallery.length < 2; | |
| root.hidden = false; | |
| document.body.classList.add("gilb-lock"); | |
| els.close.focus(); | |
| } | |
| function openFrom(img) { | |
| gallery = collectGallery(); | |
| const index = gallery.indexOf(img); | |
| lastFocus = document.activeElement; | |
| show(index === -1 ? 0 : index); | |
| } | |
| function close() { | |
| if (root.hidden) return; | |
| root.hidden = true; | |
| els.image.removeAttribute("src"); | |
| document.body.classList.remove("gilb-lock"); | |
| activeIndex = -1; | |
| if (lastFocus && typeof lastFocus.focus === "function") lastFocus.focus(); | |
| } | |
| function step(delta) { | |
| if (gallery.length < 2) return; | |
| const next = (activeIndex + delta + gallery.length) % gallery.length; | |
| show(next); | |
| } | |
| els.image.addEventListener("error", () => { | |
| els.image.hidden = true; | |
| els.error.hidden = false; | |
| }); | |
| root.addEventListener("click", (event) => { | |
| if (event.target.closest("[data-gilb-close]")) { | |
| event.preventDefault(); | |
| close(); | |
| } | |
| }); | |
| els.prev.addEventListener("click", (event) => { | |
| event.preventDefault(); | |
| step(-1); | |
| }); | |
| els.next.addEventListener("click", (event) => { | |
| event.preventDefault(); | |
| step(1); | |
| }); | |
| document.addEventListener( | |
| "click", | |
| (event) => { | |
| if (isModifiedClick(event) || event.defaultPrevented) return; | |
| const img = event.target.closest(MARKDOWN_IMAGE); | |
| if (!img) return; | |
| const link = img.closest("a"); | |
| if (!shouldLightbox(img, link)) return; | |
| event.preventDefault(); | |
| event.stopImmediatePropagation(); | |
| openFrom(img); | |
| }, | |
| true | |
| ); | |
| document.addEventListener("keydown", (event) => { | |
| if (root.hidden) return; | |
| if (event.key === "Escape") { | |
| event.preventDefault(); | |
| close(); | |
| } else if (event.key === "ArrowLeft") { | |
| event.preventDefault(); | |
| step(-1); | |
| } else if (event.key === "ArrowRight") { | |
| event.preventDefault(); | |
| step(1); | |
| } | |
| }); | |
| function markZoomable() { | |
| document.querySelectorAll(MARKDOWN_IMAGE).forEach((img) => { | |
| img.classList.toggle("gilb-zoom", shouldLightbox(img, img.closest("a"))); | |
| }); | |
| } | |
| markZoomable(); | |
| document.addEventListener("turbo:load", markZoomable); | |
| document.addEventListener("turbo:render", markZoomable); | |
| let markTimer = 0; | |
| new MutationObserver(() => { | |
| if (markTimer) return; | |
| markTimer = requestAnimationFrame(() => { | |
| markTimer = 0; | |
| markZoomable(); | |
| }); | |
| }).observe(document.body || document.documentElement, { | |
| childList: true, | |
| subtree: true, | |
| }); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment