GitHub Image Preview

A userscript that adds clickable image thumbnails

目前為 2023-07-01 提交的版本,檢視 最新版本

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