- // ==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
- // @match 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://greatest.deepsurf.us/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'>··</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();
- })();