Greasy Fork is available in English.

GitHub Code Folding

A userscript that adds code folding to GitHub files

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