GitHub Image Preview

A userscript that adds clickable image thumbnails

目前為 2021-11-30 提交的版本,檢視 最新版本

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