GitHub Code Folding

A userscript that adds code folding to GitHub files

2020-09-08 يوللانغان نەشرى. ئەڭ يېڭى نەشرىنى كۆرۈش.

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