GitHub Image Preview

A userscript that adds clickable image thumbnails

目前為 2021-01-31 提交的版本,檢視 最新版本

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