Preview GitHub markdown images in a viewer with switching, wheel zoom, and drag pan.
// ==UserScript==
// @name GitHub README Image Viewer
// @namespace https://github.com/
// @version 1.0.4
// @description Preview GitHub markdown images in a viewer with switching, wheel zoom, and drag pan.
// @author Tommy
// @match https://github.com/*
// @run-at document-idle
// @grant GM_addStyle
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const ROOT_SELECTORS = [
'article.markdown-body',
'.markdown-body',
'.js-comment-body',
'[data-testid="markdown-body"]'
];
const BADGE_HOSTS = [
'shields.io',
'badgen.net',
'badge.fury.io',
'poser.pugx.org',
'nodei.co'
];
const BADGE_TEXT_PATTERN = /(?:badge|shield|build|status|ci|coverage|version|license|npm|pypi|downloads|release|codecov|coveralls|sonarcloud|quality|dependencies|dependabot)/i;
const PREVIEW_MIN_DIMENSION = 100;
const MIN_SCALE = 0.2;
const MAX_SCALE = 8;
const ZOOM_STEP = 1.16;
let imageItems = [];
let currentIndex = 0;
let viewer = null;
let scanTimer = 0;
let previousBodyOverflow = '';
let state = {
scale: 1,
x: 0,
y: 0,
dragging: false,
dragStartX: 0,
dragStartY: 0,
startX: 0,
startY: 0
};
const styleText = `
.ghiv-ready { cursor: zoom-in !important; }
.ghiv-overlay {
position: fixed;
inset: 0;
z-index: 2147483647;
display: none;
grid-template-rows: auto minmax(0, 1fr) auto auto;
gap: 10px;
box-sizing: border-box;
padding: 16px;
color: #f0f6fc;
background: rgba(1, 4, 9, 0.82);
backdrop-filter: blur(10px);
font: 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.ghiv-overlay.ghiv-open { display: grid; }
.ghiv-toolbar {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 0;
}
.ghiv-counter {
min-width: 88px;
text-align: center;
color: #c9d1d9;
font-variant-numeric: tabular-nums;
}
.ghiv-nav-btn {
position: fixed;
top: 50%;
z-index: 1;
width: 56px;
height: 72px;
border: 1px solid rgba(240, 246, 252, 0.22);
border-radius: 8px;
padding: 0;
display: grid;
place-items: center;
color: #f0f6fc;
background: rgba(48, 54, 61, 0.78);
font: 42px/1 Georgia, "Times New Roman", serif;
cursor: pointer;
transform: translateY(-50%);
user-select: none;
}
.ghiv-nav-prev { left: 18px; }
.ghiv-nav-next { right: 18px; }
.ghiv-nav-btn:hover {
border-color: rgba(88, 166, 255, 0.78);
background: rgba(56, 139, 253, 0.28);
color: #fff;
}
.ghiv-btn, .ghiv-link {
height: 32px;
min-width: 32px;
border: 1px solid rgba(240, 246, 252, 0.22);
border-radius: 6px;
padding: 0 10px;
color: #f0f6fc;
background: rgba(48, 54, 61, 0.88);
text-decoration: none;
cursor: pointer;
user-select: none;
}
.ghiv-btn:hover, .ghiv-link:hover {
border-color: rgba(88, 166, 255, 0.7);
background: rgba(56, 139, 253, 0.24);
color: #fff;
text-decoration: none;
}
.ghiv-stage {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
display: grid;
place-items: center;
border-radius: 8px;
}
.ghiv-image {
max-width: calc(100vw - 160px);
max-height: calc(100vh - 190px);
transform-origin: center center;
transition: transform 90ms ease-out;
cursor: grab;
user-select: none;
-webkit-user-drag: none;
box-shadow: 0 18px 80px rgba(0, 0, 0, 0.45);
}
.ghiv-dragging .ghiv-image {
cursor: grabbing;
transition: none;
}
.ghiv-caption {
min-height: 18px;
max-width: min(100%, 980px);
justify-self: center;
color: #c9d1d9;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ghiv-strip {
display: flex;
gap: 8px;
min-height: 54px;
max-width: 100%;
overflow-x: auto;
padding: 2px 2px 8px;
justify-self: center;
}
.ghiv-thumb {
width: 56px;
height: 42px;
flex: 0 0 auto;
border: 2px solid transparent;
border-radius: 6px;
padding: 0;
background: rgba(48, 54, 61, 0.72);
cursor: pointer;
overflow: hidden;
}
.ghiv-thumb[aria-current="true"] { border-color: #58a6ff; }
.ghiv-thumb img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.ghiv-hint {
justify-self: center;
color: #8b949e;
font-size: 12px;
text-align: center;
}
@media (max-width: 720px) {
.ghiv-overlay { padding: 10px; gap: 8px; }
.ghiv-toolbar { justify-content: flex-start; overflow-x: auto; padding-bottom: 2px; }
.ghiv-nav-btn { width: 44px; height: 58px; font-size: 34px; }
.ghiv-nav-prev { left: 8px; }
.ghiv-nav-next { right: 8px; }
.ghiv-image { max-width: calc(100vw - 24px); max-height: calc(100vh - 184px); }
.ghiv-caption { max-width: calc(100vw - 24px); }
}
`;
addStyle(styleText);
scheduleScan();
observeGitHubNavigation();
document.addEventListener('click', onDocumentClick, true);
document.addEventListener('keydown', onKeyDown, true);
function addStyle(css) {
if (typeof GM_addStyle === 'function') {
GM_addStyle(css);
return;
}
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
}
function observeGitHubNavigation() {
['turbo:load', 'turbo:render', 'pjax:end'].forEach((eventName) => {
document.addEventListener(eventName, scheduleScan);
});
const observer = new MutationObserver((mutations) => {
const hasPageChange = mutations.some((mutation) => {
if (isViewerNode(mutation.target)) return false;
return Array.from(mutation.addedNodes).some((node) => !isViewerNode(node));
});
if (hasPageChange) {
scheduleScan();
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
}
function scheduleScan() {
window.clearTimeout(scanTimer);
scanTimer = window.setTimeout(scanImages, 120);
}
function scanImages() {
imageItems = collectImages();
imageItems.forEach((item) => {
item.img.classList.add('ghiv-ready');
if (!item.img.title) {
item.img.title = '点击预览图片';
}
});
if (viewer && viewer.root.classList.contains('ghiv-open')) {
renderThumbs();
updateCounter();
}
}
function collectImages() {
const nodes = new Set();
ROOT_SELECTORS.forEach((selector) => {
document.querySelectorAll(`${selector} img`).forEach((img) => nodes.add(img));
});
return Array.from(nodes)
.filter(isPreviewableImage)
.map((img) => {
const src = normalizeUrl(img.currentSrc || img.src || img.getAttribute('src') || img.dataset.canonicalSrc || '');
const original = normalizeUrl(img.dataset.canonicalSrc || src);
return {
img,
src,
original,
alt: (img.getAttribute('alt') || img.getAttribute('aria-label') || '').trim()
};
})
.filter((item) => item.src);
}
function isPreviewableImage(img) {
if (!(img instanceof HTMLImageElement)) return false;
if (!isInsideMarkdown(img)) return false;
if (img.closest('g-emoji, .emoji, .avatar, .octicon, .reaction-summary-item')) return false;
const src = img.currentSrc || img.src || img.getAttribute('src') || img.dataset.canonicalSrc || '';
if (!src || src.startsWith('data:')) return false;
const rect = img.getBoundingClientRect();
const width = img.naturalWidth || img.width || rect.width;
const height = img.naturalHeight || img.height || rect.height;
if (width < PREVIEW_MIN_DIMENSION || height < PREVIEW_MIN_DIMENSION) return false;
if (rect.width === 0 && rect.height === 0) return false;
if (isBadgeImage(img, src, width, height)) return false;
return true;
}
function isInsideMarkdown(element) {
return ROOT_SELECTORS.some((selector) => Boolean(element.closest(selector)));
}
function normalizeUrl(value) {
if (!value) return '';
try {
return new URL(value, location.href).href;
} catch (_) {
return value;
}
}
function isBadgeImage(img, src, width, height) {
const link = img.closest('a');
const href = link ? normalizeUrl(link.getAttribute('href') || '') : '';
const alt = img.getAttribute('alt') || '';
const title = img.getAttribute('title') || '';
const text = `${src} ${href} ${alt} ${title}`;
const badgeShape = width <= 260 && height <= 42;
try {
const url = new URL(src, location.href);
const host = url.hostname.toLowerCase();
const path = url.pathname.toLowerCase();
if (BADGE_HOSTS.some((badgeHost) => host === badgeHost || host.endsWith(`.${badgeHost}`))) {
return true;
}
if (/\/actions\/workflows\/[^/]+\/badge\.svg$/i.test(path)) {
return true;
}
if (path.endsWith('/badge.svg') && badgeShape) {
return true;
}
} catch (_) {
// Fall through to the text and shape heuristic below.
}
return badgeShape && BADGE_TEXT_PATTERN.test(text);
}
function isViewerNode(node) {
if (!viewer || !node) return false;
if (node === viewer.root) return true;
if (node instanceof Node && viewer.root.contains(node)) return true;
return false;
}
function onDocumentClick(event) {
const target = event.target instanceof Element ? event.target : null;
const img = target ? target.closest('img') : null;
if (!img || !isInsideMarkdown(img)) return;
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
scanImages();
const index = imageItems.findIndex((item) => item.img === img);
if (index < 0) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
openViewer(index);
}
function ensureViewer() {
if (viewer) return viewer;
const root = document.createElement('div');
root.className = 'ghiv-overlay';
root.setAttribute('role', 'dialog');
root.setAttribute('aria-modal', 'true');
root.setAttribute('aria-label', 'GitHub 图片预览');
root.tabIndex = -1;
root.innerHTML = `
<div class="ghiv-toolbar">
<div class="ghiv-counter">0 / 0</div>
<button class="ghiv-btn" type="button" data-action="zoom-out" aria-label="缩小">−</button>
<button class="ghiv-btn" type="button" data-action="fit" aria-label="适应窗口">适应</button>
<button class="ghiv-btn" type="button" data-action="zoom-in" aria-label="放大">+</button>
<a class="ghiv-link" target="_blank" rel="noopener noreferrer" data-role="open-original">原图</a>
<button class="ghiv-btn" type="button" data-action="close" aria-label="关闭">×</button>
</div>
<button class="ghiv-nav-btn ghiv-nav-prev" type="button" data-action="prev" aria-label="上一张">‹</button>
<button class="ghiv-nav-btn ghiv-nav-next" type="button" data-action="next" aria-label="下一张">›</button>
<div class="ghiv-stage" data-role="stage">
<img class="ghiv-image" alt="" draggable="false" data-role="image">
</div>
<div class="ghiv-caption" data-role="caption"></div>
<div class="ghiv-strip" data-role="strip" aria-label="当前页面图片列表"></div>
<div class="ghiv-hint">滚轮缩放,拖动平移,←/→ 切换,Esc 关闭,双击还原</div>
`;
document.body.appendChild(root);
viewer = {
root,
stage: root.querySelector('[data-role="stage"]'),
image: root.querySelector('[data-role="image"]'),
caption: root.querySelector('[data-role="caption"]'),
counter: root.querySelector('.ghiv-counter'),
strip: root.querySelector('[data-role="strip"]'),
originalLink: root.querySelector('[data-role="open-original"]')
};
root.addEventListener('click', onViewerClick);
viewer.stage.addEventListener('wheel', onWheel, { passive: false });
viewer.image.addEventListener('pointerdown', onPointerDown);
viewer.image.addEventListener('dblclick', resetTransform);
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('resize', resetTransform);
return viewer;
}
function openViewer(index) {
const ui = ensureViewer();
previousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
ui.root.classList.add('ghiv-open');
ui.root.focus({ preventScroll: true });
renderThumbs();
showImage(index);
}
function closeViewer() {
if (!viewer || !viewer.root.classList.contains('ghiv-open')) return;
viewer.root.classList.remove('ghiv-open', 'ghiv-dragging');
document.body.style.overflow = previousBodyOverflow;
state.dragging = false;
}
function showImage(index) {
if (!imageItems.length || !viewer) return;
currentIndex = (index + imageItems.length) % imageItems.length;
const item = imageItems[currentIndex];
viewer.image.src = item.src;
viewer.image.alt = item.alt || 'GitHub 图片预览';
viewer.caption.textContent = item.alt || item.original || item.src;
viewer.originalLink.href = item.original || item.src;
resetTransform();
updateCounter();
syncActiveThumb();
}
function renderThumbs() {
if (!viewer) return;
viewer.strip.textContent = '';
imageItems.forEach((item, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'ghiv-thumb';
button.dataset.index = String(index);
button.setAttribute('aria-label', `查看第 ${index + 1} 张图片`);
const thumb = document.createElement('img');
thumb.src = item.src;
thumb.alt = item.alt || '';
thumb.loading = 'lazy';
button.appendChild(thumb);
viewer.strip.appendChild(button);
});
syncActiveThumb();
}
function syncActiveThumb() {
if (!viewer) return;
viewer.strip.querySelectorAll('.ghiv-thumb').forEach((button) => {
const active = Number(button.dataset.index) === currentIndex;
button.setAttribute('aria-current', active ? 'true' : 'false');
if (active) button.scrollIntoView({ block: 'nearest', inline: 'center' });
});
}
function updateCounter() {
if (!viewer) return;
viewer.counter.textContent = `${currentIndex + 1} / ${imageItems.length}`;
}
function onViewerClick(event) {
const target = event.target instanceof Element ? event.target : null;
if (!target || !viewer) return;
const actionButton = target.closest('[data-action]');
if (actionButton) {
const action = actionButton.dataset.action;
if (action === 'prev') showImage(currentIndex - 1);
if (action === 'next') showImage(currentIndex + 1);
if (action === 'zoom-in') zoomBy(ZOOM_STEP);
if (action === 'zoom-out') zoomBy(1 / ZOOM_STEP);
if (action === 'fit') resetTransform();
if (action === 'close') closeViewer();
return;
}
const thumbButton = target.closest('.ghiv-thumb');
if (thumbButton) {
showImage(Number(thumbButton.dataset.index));
return;
}
if (target === viewer.root || target === viewer.stage) {
closeViewer();
}
}
function onKeyDown(event) {
if (!viewer || !viewer.root.classList.contains('ghiv-open')) return;
if (event.key === 'Escape') {
event.preventDefault();
closeViewer();
} else if (event.key === 'ArrowLeft') {
event.preventDefault();
showImage(currentIndex - 1);
} else if (event.key === 'ArrowRight') {
event.preventDefault();
showImage(currentIndex + 1);
} else if (event.key === '+' || event.key === '=') {
event.preventDefault();
zoomBy(ZOOM_STEP);
} else if (event.key === '-' || event.key === '_') {
event.preventDefault();
zoomBy(1 / ZOOM_STEP);
} else if (event.key === '0') {
event.preventDefault();
resetTransform();
}
}
function onWheel(event) {
if (!viewer || !viewer.root.classList.contains('ghiv-open')) return;
event.preventDefault();
const factor = event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
zoomBy(factor);
}
function zoomBy(factor) {
state.scale = clamp(state.scale * factor, MIN_SCALE, MAX_SCALE);
applyTransform();
}
function resetTransform() {
state.scale = 1;
state.x = 0;
state.y = 0;
applyTransform();
}
function applyTransform() {
if (!viewer) return;
viewer.image.style.transform = `translate3d(${state.x}px, ${state.y}px, 0) scale(${state.scale})`;
}
function onPointerDown(event) {
if (!viewer || event.button !== 0) return;
state.dragging = true;
state.dragStartX = event.clientX;
state.dragStartY = event.clientY;
state.startX = state.x;
state.startY = state.y;
viewer.root.classList.add('ghiv-dragging');
viewer.image.setPointerCapture?.(event.pointerId);
}
function onPointerMove(event) {
if (!state.dragging || !viewer) return;
state.x = state.startX + event.clientX - state.dragStartX;
state.y = state.startY + event.clientY - state.dragStartY;
applyTransform();
}
function onPointerUp(event) {
if (!state.dragging || !viewer) return;
state.dragging = false;
viewer.root.classList.remove('ghiv-dragging');
viewer.image.releasePointerCapture?.(event.pointerId);
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
})();