GitHub Code Folding

A userscript that adds code folding to GitHub files

目前為 2021-02-21 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name GitHub Code Folding
  3. // @version 1.1.2
  4. // @description A userscript that adds code folding to GitHub files
  5. // @license MIT
  6. // @author Rob Garrison
  7. // @namespace https://github.com/Mottie
  8. // @include https://github.com/*
  9. // @include https://gist.github.com/*
  10. // @run-at document-idle
  11. // @grant GM.addStyle
  12. // @grant GM_addStyle
  13. // @require https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js?updated=20180103
  14. // @require https://greasyfork.org/scripts/28721-mutations/code/mutations.js?version=882023
  15. // @icon https://github.githubassets.com/pinned-octocat.svg
  16. // @supportURL https://github.com/Mottie/GitHub-userscripts/issues
  17. // ==/UserScript==
  18. /* global GM */
  19. /**
  20. * This userscript has been heavily modified from the "github-code-folding"
  21. * Chrome extension Copyright 2016 by Noam Lustiger; under an MIT license
  22. * https://github.com/noam3127/github-code-folding
  23. */
  24. (() => {
  25. "use strict";
  26.  
  27. GM.addStyle(`
  28. td.blob-code.blob-code-inner { position:relative; padding-left:10px; }
  29. .ghcf-collapser { position:absolute; left:2px; width:10px; cursor:pointer; }
  30. .ghcf-collapser:after { display: inline-block; vertical-align: middle;
  31. content:"\u25bc"; opacity:.5; transition:.15s; }
  32. .ghcf-collapser:hover:after { opacity:1; }
  33. .ghcf-collapsed.ghcf-collapser:after { transform:rotate(-90deg);
  34. opacity:.8; }
  35. .ghcf-hidden-line { display:none; }
  36. .ghcf-ellipsis { padding:1px 2px; margin-left:2px; cursor:pointer;
  37. background:rgba(255,235,59,.4); position:relative; z-index:1; }
  38. .ghcf-ellipsis:hover { background:rgba(255,235,59,.7); }
  39. `);
  40.  
  41. const blocks = {};
  42. const ellipsis = document.createElement("span");
  43. const triangle = document.createElement("span");
  44.  
  45. triangle.className = "ghcf-collapser";
  46. ellipsis.className = "pl-smi ghcf-ellipsis";
  47. ellipsis.innerHTML = "…";
  48.  
  49. function countInitialWhiteSpace(arr) {
  50. const getWhiteSpaceIndex = i => {
  51. if (arr[i] !== " " && arr[i] !== "\t" && arr[i] !== "\xa0") {
  52. return i;
  53. }
  54. return getWhiteSpaceIndex(++i);
  55. };
  56. return getWhiteSpaceIndex(0);
  57. }
  58.  
  59. function getPreviousSpaces(map, lineNum) {
  60. let prev = map.get(lineNum - 1);
  61. return prev === -1
  62. ? getPreviousSpaces(map, lineNum - 1)
  63. : {
  64. lineNum: lineNum - 1,
  65. count: prev
  66. };
  67. }
  68.  
  69. function getLineNumber(el) {
  70. let elm = el.closest("tr");
  71. if (elm) {
  72. elm = elm.querySelector("[data-line-number]");
  73. return elm ? parseInt(elm.dataset.lineNumber, 10) : "";
  74. }
  75. return "";
  76. }
  77.  
  78. function getCodeLines(codeBlock) {
  79. return $$(".blob-code-inner", codeBlock);
  80. }
  81.  
  82. function toggleCode({ action, codeBlock, index, depth }) {
  83. let els, lineNums;
  84. const codeLines = getCodeLines(codeBlock) || [];
  85. const pairs = blocks[codeBlock.dataset.blockIndex];
  86. if (!pairs || codeLines.length === 0) {
  87. return;
  88. }
  89. // depth is a string containing a specific depth number to toggle
  90. if (depth) {
  91. els = $$(`.ghcf-collapser[data-depth="${depth}"]`, codeBlock);
  92. lineNums = els.map(el => {
  93. el.classList.toggle("ghcf-collapsed", action === "hide");
  94. return getLineNumber(el);
  95. });
  96. } else {
  97. lineNums = [index];
  98. }
  99.  
  100. if (action === "hide") {
  101. lineNums.forEach(start => {
  102. let elm;
  103. let end = pairs.get(start - 1);
  104. codeLines.slice(start, end).forEach(el => {
  105. elm = el.closest("tr");
  106. if (elm) {
  107. elm.classList.add("ghcf-hidden-line");
  108. }
  109. });
  110. if (!$(".ghcf-ellipsis", codeLines[start - 1])) {
  111. elm = $(".ghcf-collapser", codeLines[start - 1]);
  112. elm.parentNode.insertBefore(
  113. ellipsis.cloneNode(true),
  114. elm.nextSibling
  115. );
  116. }
  117. });
  118. } else if (action === "show") {
  119. lineNums.forEach(start => {
  120. let end = pairs.get(start - 1);
  121. codeLines.slice(start, end).forEach(el => {
  122. let elm = el.closest("tr");
  123. if (elm) {
  124. elm.classList.remove("ghcf-hidden-line");
  125. removeEls(".ghcf-ellipsis", elm);
  126. }
  127. elm = $(".ghcf-collapsed", elm);
  128. if (elm) {
  129. elm.classList.remove("ghcf-collapsed");
  130. }
  131. });
  132. removeEls(".ghcf-ellipsis", codeLines[start - 1]);
  133. });
  134. }
  135. // shift ends up selecting text on the page, so clear it
  136. if (lineNums.length > 1) {
  137. removeSelection();
  138. }
  139. }
  140.  
  141. function addBindings() {
  142. document.addEventListener("click", event => {
  143. let index, elm, isCollapsed;
  144. const el = event.target;
  145. const codeBlock = el.closest(".highlight");
  146.  
  147. // click on collapser
  148. if (el && el.classList.contains("ghcf-collapser")) {
  149. isCollapsed = el.classList.contains("ghcf-collapsed");
  150. index = getLineNumber(el);
  151. // Shift + click to toggle them all
  152. if (index && event.getModifierState("Shift")) {
  153. return toggleCode({
  154. action: isCollapsed ? "show" : "hide",
  155. codeBlock,
  156. index,
  157. depth: el.dataset.depth
  158. });
  159. }
  160. if (index) {
  161. if (isCollapsed) {
  162. el.classList.remove("ghcf-collapsed");
  163. toggleCode({ action: "show", codeBlock, index });
  164. } else {
  165. el.classList.add("ghcf-collapsed");
  166. toggleCode({ action: "hide", codeBlock, index });
  167. }
  168. }
  169. return;
  170. }
  171.  
  172. // click on ellipsis
  173. if (el && el.classList.contains("ghcf-ellipsis")) {
  174. elm = $(".ghcf-collapsed", el.parentNode);
  175. if (elm) {
  176. elm.classList.remove("ghcf-collapsed");
  177. }
  178. index = getLineNumber(el);
  179. if (index) {
  180. toggleCode({ action: "show", codeBlock, index });
  181. }
  182. }
  183. });
  184. }
  185.  
  186. function addCodeFolding() {
  187. // Keep .file in case someone needs this userscript for GitHub Enterprise
  188. if ($(".file table.highlight, .blob-wrapper table.highlight")) {
  189. $$("table.highlight").forEach((codeBlock, blockIndex) => {
  190. if (codeBlock && codeBlock.classList.contains("ghcf-processed")) {
  191. // Already processed
  192. return;
  193. }
  194. const codeLines = getCodeLines(codeBlock);
  195. removeEls("span.ghcf-collapser", codeBlock);
  196. if (codeLines) {
  197. // In case this script has already been run and modified the DOM on a
  198. // previous page in github, make sure to reset it.
  199. codeBlock.classList.add("ghcf-processed");
  200. codeBlock.dataset.blockIndex = blockIndex;
  201.  
  202. const spaceMap = new Map();
  203. const stack = [];
  204. const pairs = blocks[blockIndex] = new Map();
  205.  
  206. codeLines.forEach((el, lineNum) => {
  207. let prevSpaces;
  208. let line = el.textContent;
  209. let count = line.trim().length
  210. ? countInitialWhiteSpace(line.split(""))
  211. : -1;
  212. spaceMap.set(lineNum, count);
  213.  
  214. function tryPair() {
  215. let el;
  216. let top = stack[stack.length - 1];
  217. if (count !== -1 && count <= spaceMap.get(top)) {
  218. pairs.set(top, lineNum);
  219. // prepend triangle
  220. el = triangle.cloneNode();
  221. el.dataset.depth = count + 1;
  222. codeLines[top].appendChild(el, codeLines[top].childNodes[0]);
  223. stack.pop();
  224. return tryPair();
  225. }
  226. }
  227. tryPair();
  228.  
  229. prevSpaces = getPreviousSpaces(spaceMap, lineNum);
  230. if (count > prevSpaces.count) {
  231. stack.push(prevSpaces.lineNum);
  232. }
  233. });
  234. }
  235. });
  236. }
  237. }
  238.  
  239. function $(selector, el) {
  240. return (el || document).querySelector(selector);
  241. }
  242.  
  243. function $$(selector, el) {
  244. return Array.from((el || document).querySelectorAll(selector));
  245. }
  246.  
  247. function removeEls(selector, el) {
  248. let els = $$(selector, el);
  249. let index = els.length;
  250. while (index--) {
  251. els[index].parentNode.removeChild(els[index]);
  252. }
  253. }
  254.  
  255. function removeSelection() {
  256. // remove text selection - https://stackoverflow.com/a/3171348/145346
  257. const sel = window.getSelection
  258. ? window.getSelection()
  259. : document.selection;
  260. if (sel) {
  261. if (sel.removeAllRanges) {
  262. sel.removeAllRanges();
  263. } else if (sel.empty) {
  264. sel.empty();
  265. }
  266. }
  267. }
  268.  
  269. document.addEventListener("ghmo:container", addCodeFolding);
  270. addCodeFolding();
  271. addBindings();
  272.  
  273. })();