GitHub Image Preview

A userscript that adds clickable image thumbnails

  1. // ==UserScript==
  2. // @name GitHub Image Preview
  3. // @version 2.0.8
  4. // @description A userscript that adds clickable image thumbnails
  5. // @license MIT
  6. // @author Rob Garrison
  7. // @namespace https://github.com/Mottie
  8. // @match https://github.com/*
  9. // @run-at document-idle
  10. // @grant GM_addStyle
  11. // @grant GM_getValue
  12. // @grant GM_setValue
  13. // @grant GM_xmlhttpRequest
  14. // @connect github.com
  15. // @connect githubusercontent.com
  16. // @require https://greatest.deepsurf.us/scripts/28721-mutations/code/mutations.js?version=1108163
  17. // @icon https://github.githubassets.com/pinned-octocat.svg
  18. // @supportURL https://github.com/Mottie/GitHub-userscripts/issues
  19. // ==/UserScript==
  20.  
  21. (() => {
  22. "use strict";
  23.  
  24. GM_addStyle(`
  25. .ghip-wrapper .ghip-content { display:none; }
  26. .ghip-wrapper.ghip-show-previews .ghip-content { display:flex; width:100%; }
  27. .ghip-wrapper.ghip-show-previews .Box-row { border:0 !important;
  28. background-color:transparent !important; }
  29. .ghip-show-previews .Box-row:not(.ghsc-header):not(.hidden) > div[role] {
  30. display:none !important; }
  31. .ghip-wrapper.ghip-show-previews svg.ghip-non-image,
  32. .ghip-wrapper.ghip-show-previews img.ghip-non-image { height:80px; width:80px;
  33. margin-top:15px; }
  34. .ghip-wrapper.ghip-show-previews .image { width:100%; position:relative;
  35. overflow:hidden; text-align:center; }
  36.  
  37. .ghip-wrapper.ghip-tiled .Box-row:not(.ghsc-header):not(.hidden) {
  38. width:24.5%; max-width:24.5%; justify-content:center; overflow:hidden;
  39. display:inline-flex !important; padding:8px !important; }
  40. .ghip-wrapper.ghip-tiled .image { height:180px; margin:12px !important; }
  41. .ghip-wrapper.ghip-tiled .image img,
  42. .ghip-wrapper svg { max-height:130px; max-width:90%; }
  43. /* zoom doesn't work in Firefox, but "-moz-transform:scale(3);"
  44. doesn't limit the size of the image, so it overflows */
  45. .ghip-wrapper.ghip-tiled .image:hover img:not(.ghip-non-image) { zoom:3; }
  46.  
  47. .ghip-wrapper.ghip-fullw .image { height:unset; padding-bottom:0; }
  48.  
  49. .ghip-wrapper .image span { display:block; position:relative; }
  50. .ghip-wrapper .ghip-folder { margin-bottom:2em; }
  51. .image .ghip-file-type { font-size:40px; top:-2em; left:0; z-index:2;
  52. position:relative; text-shadow:1px 1px 1px #fff, -1px 1px 1px #fff,
  53. 1px -1px 1px #fff, -1px -1px 1px #fff; }
  54. .ghip-wrapper h4 { overflow:hidden; white-space:nowrap;
  55. text-overflow:ellipsis; margin:0 12px 5px; }
  56.  
  57. .ghip-wrapper img, .ghip-wrapper svg { max-width:95%; }
  58. .ghip-wrapper img.error { border:5px solid red;
  59. border-radius:32px; }
  60. .btn.ghip-tiled > *, .btn.ghip-fullw > *, .ghip-wrapper iframe {
  61. pointer-events:none; vertical-align:baseline; }
  62. .ghip-content span.exploregrid-item .ghip-file-name { cursor:default; }
  63. /* override GitHub-Dark styles */
  64. .ghip-wrapper img[src*='octocat-spinner'], img[src='/images/spinner.gif'] {
  65. width:auto !important; height:auto !important; }
  66. .ghip-wrapper td .simplified-path { color:#888 !important; }
  67. `);
  68.  
  69. // supported img types
  70. const imgExt = /(png|jpg|jpeg|gif|tif|tiff|bmp|webp)$/i;
  71. const svgExt = /svg$/i;
  72. const spinner = "https://github.githubassets.com/images/spinners/octocat-spinner-32.gif";
  73.  
  74. const folderIconClasses = `
  75. .octicon-file-directory,
  76. .octicon-file-symlink-directory,
  77. .octicon-file-submodule`;
  78.  
  79. const tiled = `
  80. <svg class="octicon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16">
  81. <path d="M0 0h7v7H0zM9 9h7v7H9zM9 0h7v7H9zM0 9h7v7H0z"/>
  82. </svg>`;
  83.  
  84. const fullWidth = `
  85. <svg class="octicon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16">
  86. <path d="M0 0h16v7H0zM0 9h16v7H0z"/>
  87. </svg>`;
  88.  
  89. const imgTemplate = [
  90. // not using backticks here; we need to minimize extra whitespace everywhere
  91. "<a href='${url}' class='exploregrid-item image m-3 float-left js-navigation-open' rel='nofollow'>",
  92. "${content}",
  93. "</a>"
  94. ].join("");
  95.  
  96. const spanTemplate = [
  97. "<span class='exploregrid-item image m-3 float-left'>",
  98. "${content}",
  99. "</span>"
  100. ].join("");
  101.  
  102. const contentWrap = document.createElement("div");
  103. contentWrap.className = "ghip-content";
  104.  
  105. function setupWraper() {
  106. // set up wrapper
  107. const grid = $("div[role='grid']", $("#files").parentElement);
  108. if (grid) {
  109. grid.parentElement.classList.add("ghip-wrapper");
  110. }
  111. }
  112.  
  113. function addToggles() {
  114. if ($(".gh-img-preview") || !$(".file-navigation")) {
  115. return;
  116. }
  117. const div = document.createElement("div");
  118. const btn = `btn BtnGroup-item tooltipped tooltipped-n" aria-label="Show`;
  119. div.className = "BtnGroup ml-2 gh-img-preview";
  120. div.innerHTML = `
  121. <button type="button" class="ghip-tiled ${btn} tiled files with image preview">${tiled}</button>
  122. <button type="button" class="ghip-fullw ${btn} full width files with image preview">${fullWidth}</button>
  123. `;
  124. $(".file-navigation").appendChild(div);
  125.  
  126. $(".ghip-tiled", div).addEventListener("click", event => {
  127. openView("tiled", event);
  128. });
  129. $(".ghip-fullw", div).addEventListener("click", event => {
  130. openView("fullw", event);
  131. });
  132. }
  133.  
  134. function setInitState() {
  135. const state = GM_getValue("gh-image-preview");
  136. if (state) {
  137. openView(state);
  138. }
  139. }
  140.  
  141. function openView(name, event) {
  142. setupWraper();
  143. const wrap = $(".ghip-wrapper");
  144. if (!wrap) {
  145. return;
  146. }
  147. const el = $(".ghip-" + name);
  148. if (el) {
  149. if (event) {
  150. el.classList.toggle("selected");
  151. if (!el.classList.contains("selected")) {
  152. return showList();
  153. }
  154. }
  155. showPreview(name);
  156. }
  157. }
  158.  
  159. function showPreview(name) {
  160. buildPreviews();
  161. const wrap = $(".ghip-wrapper");
  162. const selected = "ghip-" + name;
  163. const notSelected = "ghip-" + (name === "fullw" ? "tiled" : "fullw");
  164. wrap.classList.add("ghip-show-previews", selected);
  165. $(".btn." + selected).classList.add("selected");
  166. wrap.classList.remove(notSelected);
  167. $(".btn." + notSelected).classList.remove("selected");
  168. GM_setValue("gh-image-preview", name);
  169. }
  170.  
  171. function showList() {
  172. const wrap = $(".ghip-wrapper");
  173. wrap.classList.remove("ghip-show-previews", "ghip-tiled", "ghip-fullw");
  174. $(".btn.ghip-tiled").classList.remove("selected");
  175. $(".btn.ghip-fullw").classList.remove("selected");
  176. GM_setValue("gh-image-preview", "");
  177. }
  178.  
  179. function buildPreviews() {
  180. const wrap = $(".ghip-wrapper");
  181. if (!wrap) {
  182. return;
  183. }
  184. $$(".Box-row", wrap).forEach(row => {
  185. let content = "";
  186. // not every submodule includes a link; reference examples from
  187. // see https://github.com/electron/electron/tree/v1.1.1/vendor
  188. const el = $("div[role='rowheader'] a, div[role='rowheader'] span[title]", row);
  189. const url = el && el.nodeName === "A" ? el.href : "";
  190. // use innerHTML because some links include path - see "third_party/lss"
  191. const fileName = el && el.textContent.trim() || "";
  192. // add link color
  193. const title = (type = "file-name") =>
  194. `<h4
  195. class="ghip-${type}"
  196. title="${fileName}"
  197. >${fileName}</h4>`;
  198.  
  199. if (el && el.title.includes("parent dir")) {
  200. // *** up tree link ***
  201. content = url ?
  202. updateTemplate(
  203. url,
  204. "<h4 class='ghip-up-tree'>&middot;&middot;</h4>"
  205. ) : "";
  206. } else if (imgExt.test(url)) {
  207. // *** image preview ***
  208. content = updateTemplate(
  209. url,
  210. `${title()}<img src='${url}?raw=true'/>`
  211. );
  212. } else if (svgExt.test(url)) {
  213. // *** svg preview ***
  214. // loaded & encoded because GitHub sets content-type headers as a string
  215. content = updateTemplate(url, `${title()}${svgPlaceholder(url)}`);
  216. } else {
  217. // *** non-images (file/folder icons) ***
  218. const svg = $("[role='gridcell'] svg, [role='gridcell'] img", row);
  219. if (svg) {
  220. // non-files svg class: "directory", "submodule" or "symlink"
  221. // add "ghip-folder" class for file-filters userscript
  222. const noExt = svg.matches(folderIconClasses) ? " ghip-folder" : "";
  223. const clone = svg.cloneNode(true);
  224. clone.classList.add("ghip-non-image");
  225. // include "leaflet-tile-container" to invert icon for GitHub-Dark
  226. content = `${title("non-image")}<span class="leaflet-tile-container${noExt}">` +
  227. clone.outerHTML + "</span>";
  228. content = url ?
  229. updateTemplate(url, content) :
  230. // empty url; use non-link template
  231. // see "depot_tools @ 4fa73b8" at
  232. // https://github.com/electron/electron/tree/v1.1.1/vendor
  233. updateTemplate(url, content, spanTemplate);
  234. }
  235. }
  236. const preview = $(".ghip-content", row) || contentWrap.cloneNode();
  237. preview.innerHTML = content;
  238. row.append(preview);
  239. });
  240. lazyLoadSVGs();
  241. }
  242.  
  243. function updateTemplate(url, content, template = imgTemplate) {
  244. return template.replace("${url}", url).replace("${content}", content);
  245. }
  246.  
  247. function svgPlaceholder(url) {
  248. const str = url.substring(url.lastIndexOf("/") + 1, url.length);
  249. return `<img data-svg-holder="${str}" data-svg-url="${url}" alt="${str}" src="${spinner}" />`;
  250. }
  251.  
  252. function lazyLoadSVGs() {
  253. const imgs = $$("[data-svg-holder]");
  254. if (imgs.length && "IntersectionObserver" in window) {
  255. let imgObserver = new IntersectionObserver(entries => {
  256. entries.forEach(entry => {
  257. if (entry.isIntersecting) {
  258. const img = entry.target;
  259. setTimeout(() => {
  260. const bounds = img.getBoundingClientRect();
  261. // Don't load all svgs when the user scrolls down the page really
  262. // fast
  263. if (bounds.top <= window.innerHeight && bounds.bottom >= 0) {
  264. getSVG(imgObserver, img);
  265. }
  266. }, 300);
  267. }
  268. });
  269. });
  270. imgs.forEach(function(img) {
  271. imgObserver.observe(img);
  272. });
  273. }
  274. }
  275.  
  276. function getSVG(observer, img) {
  277. GM_xmlhttpRequest({
  278. method: "GET",
  279. url: img.dataset.svgUrl + "?raw=true",
  280. onload: response => {
  281. const url = response.finalUrl,
  282. file = url.substring(url.lastIndexOf("/") + 1, url.length),
  283. target = $("[data-svg-holder='" + file + "']"),
  284. resp = response.responseText,
  285. // Loading too many images at once makes GitHub returns a "You have triggered
  286. // an abuse detection mechanism" message
  287. abuse = resp.includes("abuse detection");
  288. if (target && !abuse) {
  289. const encoded = window.btoa(response.responseText);
  290. target.src = "data:image/svg+xml;base64," + encoded;
  291. target.title = "";
  292. target.classList.remove("error");
  293. observer.unobserve(img);
  294. } else if (abuse) {
  295. img.title = "GitHub is reporting that too many images have been loaded at once, please wait";
  296. img.classList.add("error");
  297. }
  298. }
  299. });
  300. }
  301.  
  302. function $(selector, el) {
  303. return (el || document).querySelector(selector);
  304. }
  305. function $$(selector, el) {
  306. return [...(el || document).querySelectorAll(selector)];
  307. }
  308.  
  309. function init() {
  310. if ($("#files")) {
  311. setupWraper();
  312. addToggles();
  313. setTimeout(setInitState, 0);
  314. }
  315. }
  316.  
  317. document.addEventListener("ghmo:container", init);
  318. init();
  319. })();