GitHub Image Preview

A userscript that adds clickable image thumbnails

// ==UserScript==
// @name        GitHub Image Preview
// @version     2.0.8
// @description A userscript that adds clickable image thumbnails
// @license     MIT
// @author      Rob Garrison
// @namespace   https://github.com/Mottie
// @include     https://github.com/*
// @run-at      document-idle
// @grant       GM_addStyle
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_xmlhttpRequest
// @connect     github.com
// @connect     githubusercontent.com
// @require     https://greasyfork.org/scripts/28721-mutations/code/mutations.js?version=1108163
// @icon        https://github.githubassets.com/pinned-octocat.svg
// @supportURL  https://github.com/Mottie/GitHub-userscripts/issues
// ==/UserScript==
(() => {
	"use strict";

	GM_addStyle(`
		.ghip-wrapper .ghip-content { display:none; }
		.ghip-wrapper.ghip-show-previews .ghip-content { display:flex; width:100%; }
		.ghip-wrapper.ghip-show-previews .Box-row { border:0 !important;
			background-color:transparent !important; }
		.ghip-show-previews .Box-row:not(.ghsc-header):not(.hidden) > div[role] {
			display:none !important; }
		.ghip-wrapper.ghip-show-previews svg.ghip-non-image, 
        .ghip-wrapper.ghip-show-previews img.ghip-non-image { height:80px; width:80px;
			margin-top:15px; }
		.ghip-wrapper.ghip-show-previews .image { width:100%; position:relative;
			overflow:hidden; text-align:center; }

		.ghip-wrapper.ghip-tiled .Box-row:not(.ghsc-header):not(.hidden) {
			width:24.5%; max-width:24.5%; justify-content:center; overflow:hidden;
			display:inline-flex !important; padding:8px !important; }
		.ghip-wrapper.ghip-tiled .image { height:180px;	margin:12px !important; }
		.ghip-wrapper.ghip-tiled .image img,
			.ghip-wrapper svg { max-height:130px; max-width:90%; }
		/* zoom doesn't work in Firefox, but "-moz-transform:scale(3);"
			doesn't limit the size of the image, so it overflows */
		.ghip-wrapper.ghip-tiled .image:hover img:not(.ghip-non-image) { zoom:3; }

		.ghip-wrapper.ghip-fullw .image { height:unset; padding-bottom:0; }
		
		.ghip-wrapper .image span { display:block;	position:relative; }
		.ghip-wrapper .ghip-folder { margin-bottom:2em; }
		.image .ghip-file-type { font-size:40px; top:-2em; left:0; z-index:2;
			position:relative; text-shadow:1px 1px 1px #fff, -1px 1px 1px #fff,
			1px -1px 1px #fff, -1px -1px 1px #fff; }
		.ghip-wrapper h4 { overflow:hidden; white-space:nowrap;
			text-overflow:ellipsis; margin:0 12px 5px; }

		.ghip-wrapper img, .ghip-wrapper svg { max-width:95%; }
		.ghip-wrapper img.error { border:5px solid red;
			border-radius:32px; }
		.btn.ghip-tiled > *, .btn.ghip-fullw > *, .ghip-wrapper iframe {
			pointer-events:none; vertical-align:baseline; }
		.ghip-content span.exploregrid-item .ghip-file-name { cursor:default; }
		/* override GitHub-Dark styles */
		.ghip-wrapper img[src*='octocat-spinner'], img[src='/images/spinner.gif'] {
			width:auto !important; height:auto !important; }
		.ghip-wrapper td .simplified-path { color:#888 !important; }
	`);

	// supported img types
	const imgExt = /(png|jpg|jpeg|gif|tif|tiff|bmp|webp)$/i;
	const svgExt = /svg$/i;
	const spinner = "https://github.githubassets.com/images/spinners/octocat-spinner-32.gif";

	const folderIconClasses = `
		.octicon-file-directory,
		.octicon-file-symlink-directory,
		.octicon-file-submodule`;

	const tiled = `
		<svg class="octicon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16">
			<path d="M0 0h7v7H0zM9 9h7v7H9zM9 0h7v7H9zM0 9h7v7H0z"/>
		</svg>`;

	const fullWidth = `
		<svg class="octicon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16">
			<path d="M0 0h16v7H0zM0 9h16v7H0z"/>
		</svg>`;

	const imgTemplate = [
		// not using backticks here; we need to minimize extra whitespace everywhere
		"<a href='${url}' class='exploregrid-item image m-3 float-left js-navigation-open' rel='nofollow'>",
		"${content}",
		"</a>"
	].join("");

	const spanTemplate = [
		"<span class='exploregrid-item image m-3 float-left'>",
		"${content}",
		"</span>"
	].join("");

	const contentWrap = document.createElement("div");
	contentWrap.className = "ghip-content";

	function setupWraper() {
		// set up wrapper
		const grid = $("div[role='grid']", $("#files").parentElement);
		if (grid) {
			grid.parentElement.classList.add("ghip-wrapper");
		}
	}

	function addToggles() {
		if ($(".gh-img-preview") || !$(".file-navigation")) {
			return;
		}
		const div = document.createElement("div");
		const btn = `btn BtnGroup-item tooltipped tooltipped-n" aria-label="Show`;
		div.className = "BtnGroup ml-2 gh-img-preview";
		div.innerHTML = `
			<button type="button" class="ghip-tiled ${btn} tiled files with image preview">${tiled}</button>
			<button type="button" class="ghip-fullw ${btn} full width files with image preview">${fullWidth}</button>
		`;
		$(".file-navigation").appendChild(div);

		$(".ghip-tiled", div).addEventListener("click", event => {
			openView("tiled", event);
		});
		$(".ghip-fullw", div).addEventListener("click", event => {
			openView("fullw", event);
		});
	}

	function setInitState() {
		const state = GM_getValue("gh-image-preview");
		if (state) {
			openView(state);
		}
	}

	function openView(name, event) {
		setupWraper();
		const wrap = $(".ghip-wrapper");
		if (!wrap) {
			return;
		}
		const el = $(".ghip-" + name);
		if (el) {
			if (event) {
				el.classList.toggle("selected");
				if (!el.classList.contains("selected")) {
					return showList();
				}
			}
			showPreview(name);
		}
	}

	function showPreview(name) {
		buildPreviews();
		const wrap = $(".ghip-wrapper");
		const selected = "ghip-" + name;
		const notSelected = "ghip-" + (name === "fullw" ? "tiled" : "fullw");
		wrap.classList.add("ghip-show-previews", selected);
		$(".btn." + selected).classList.add("selected");
		wrap.classList.remove(notSelected);
		$(".btn." + notSelected).classList.remove("selected");
		GM_setValue("gh-image-preview", name);
	}

	function showList() {
		const wrap = $(".ghip-wrapper");
		wrap.classList.remove("ghip-show-previews", "ghip-tiled", "ghip-fullw");
		$(".btn.ghip-tiled").classList.remove("selected");
		$(".btn.ghip-fullw").classList.remove("selected");
		GM_setValue("gh-image-preview", "");
	}

	function buildPreviews() {
		const wrap = $(".ghip-wrapper");
		if (!wrap) {
			return;
		}
		$$(".Box-row", wrap).forEach(row => {
			let content = "";
			// not every submodule includes a link; reference examples from
			// see https://github.com/electron/electron/tree/v1.1.1/vendor
			const el = $("div[role='rowheader'] a, div[role='rowheader'] span[title]", row);
			const url = el && el.nodeName === "A" ? el.href : "";
			// use innerHTML because some links include path - see "third_party/lss"
			const fileName = el && el.textContent.trim() || "";
			// add link color
			const title = (type = "file-name") =>
				`<h4
					class="ghip-${type}"
					title="${fileName}"
				>${fileName}</h4>`;

			if (el && el.title.includes("parent dir")) {
				// *** up tree link ***
				content = url ?
					updateTemplate(
						url,
						"<h4 class='ghip-up-tree'>&middot;&middot;</h4>"
					) : "";
			} else if (imgExt.test(url)) {
				// *** image preview ***
				content = updateTemplate(
					url,
					`${title()}<img src='${url}?raw=true'/>`
				);
			} else if (svgExt.test(url)) {
				// *** svg preview ***
				// loaded & encoded because GitHub sets content-type headers as a string
				content = updateTemplate(url, `${title()}${svgPlaceholder(url)}`);
			} else {
				// *** non-images (file/folder icons) ***
				const svg = $("[role='gridcell'] svg, [role='gridcell'] img", row);
				if (svg) {
					// non-files svg class: "directory", "submodule" or "symlink"
					// add "ghip-folder" class for file-filters userscript
					const noExt = svg.matches(folderIconClasses) ? " ghip-folder" : "";
					const clone = svg.cloneNode(true);
					clone.classList.add("ghip-non-image");
					// include "leaflet-tile-container" to invert icon for GitHub-Dark
					content = `${title("non-image")}<span class="leaflet-tile-container${noExt}">` +
						clone.outerHTML + "</span>";
					content = url ?
						updateTemplate(url, content) :
						// empty url; use non-link template
						// see "depot_tools @ 4fa73b8" at
						// https://github.com/electron/electron/tree/v1.1.1/vendor
						updateTemplate(url, content, spanTemplate);
				}
			}
			const preview = $(".ghip-content", row) || contentWrap.cloneNode();
			preview.innerHTML = content;
			row.append(preview);
		});
		lazyLoadSVGs();
	}

	function updateTemplate(url, content, template = imgTemplate) {
		return template.replace("${url}", url).replace("${content}", content);
	}

	function svgPlaceholder(url) {
		const str = url.substring(url.lastIndexOf("/") + 1, url.length);
		return `<img data-svg-holder="${str}" data-svg-url="${url}" alt="${str}" src="${spinner}" />`;
	}

	function lazyLoadSVGs() {
		const imgs = $$("[data-svg-holder]");
		if (imgs.length && "IntersectionObserver" in window) {
			let imgObserver = new IntersectionObserver(entries => {
				entries.forEach(entry => {
					if (entry.isIntersecting) {
						const img = entry.target;
						setTimeout(() => {
							const bounds = img.getBoundingClientRect();
							// Don't load all svgs when the user scrolls down the page really
							// fast
							if (bounds.top <= window.innerHeight && bounds.bottom >= 0) {
								getSVG(imgObserver, img);
							}
						}, 300);
					}
				});
			});
			imgs.forEach(function(img) {
				imgObserver.observe(img);
			});
		}
	}

	function getSVG(observer, img) {
		GM_xmlhttpRequest({
			method: "GET",
			url: img.dataset.svgUrl + "?raw=true",
			onload: response => {
				const url = response.finalUrl,
					file = url.substring(url.lastIndexOf("/") + 1, url.length),
					target = $("[data-svg-holder='" + file + "']"),
					resp = response.responseText,
					// Loading too many images at once makes GitHub returns a "You have triggered
					// an abuse detection mechanism" message
					abuse = resp.includes("abuse detection");
				if (target && !abuse) {
					const encoded = window.btoa(response.responseText);
					target.src = "data:image/svg+xml;base64," + encoded;
					target.title = "";
					target.classList.remove("error");
					observer.unobserve(img);
				} else if (abuse) {
					img.title = "GitHub is reporting that too many images have been loaded at once, please wait";
					img.classList.add("error");
				}
			}
		});
	}

	function $(selector, el) {
		return (el || document).querySelector(selector);
	}
	function $$(selector, el) {
		return [...(el || document).querySelectorAll(selector)];
	}

	function init() {
		if ($("#files")) {
			setupWraper();
			addToggles();
			setTimeout(setInitState, 0);
		}
	}

	document.addEventListener("ghmo:container", init);
	init();
})();