GitHub Collapse Markdown

A userscript that collapses markdown headers

  1. // ==UserScript==
  2. // @name GitHub Collapse Markdown
  3. // @version 1.2.3
  4. // @description A userscript that collapses markdown headers
  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. // @match https://help.github.com/*
  11. // @run-at document-idle
  12. // @grant GM_addStyle
  13. // @grant GM_getValue
  14. // @grant GM_setValue
  15. // @grant GM_registerMenuCommand
  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. (() => {
  22. "use strict";
  23.  
  24. const defaultColors = [
  25. // palette generated by http://tools.medialab.sciences-po.fr/iwanthue/
  26. // (colorblind friendly, soft)
  27. "#6778d0", "#ac9c3d", "#b94a73", "#56ae6c", "#9750a1", "#ba543d"
  28. ],
  29.  
  30. blocks = [
  31. ".markdown-body",
  32. ".markdown-format",
  33. "" // leave empty string at the end
  34. ],
  35.  
  36. headers = "H1 H2 H3 H4 H5 H6".split(" "),
  37. collapsed = "ghcm-collapsed",
  38. arrowColors = document.createElement("style");
  39.  
  40. let startCollapsed = GM_getValue("ghcm-collapsed", false),
  41. colors = GM_getValue("ghcm-colors", defaultColors);
  42.  
  43. // .markdown-body h1:after, .markdown-format h1:after, ... {}
  44. GM_addStyle(`
  45. ${blocks.join(" h1,")} ${blocks.join(" h2,")}
  46. ${blocks.join(" h3,")} ${blocks.join(" h4,")}
  47. ${blocks.join(" h5,")} ${blocks.join(" h6,").slice(0, -1)} {
  48. position:relative;
  49. padding-right:.8em;
  50. cursor:pointer;
  51. }
  52. ${blocks.join(" h1:after,")} ${blocks.join(" h2:after,")}
  53. ${blocks.join(" h3:after,")} ${blocks.join(" h4:after,")}
  54. ${blocks.join(" h5:after,")} ${blocks.join(" h6:after,").slice(0, -1)} {
  55. display:inline-block;
  56. position:absolute;
  57. right:0;
  58. top:calc(50% - .5em);
  59. font-size:.8em;
  60. content:"\u25bc";
  61. }
  62. ${blocks.join(" ." + collapsed + ":after,").slice(0, -1)} {
  63. transform: rotate(90deg);
  64. }
  65. /* clicking on header link won't pass svg as the event.target */
  66. .octicon-link, .octicon-link > * {
  67. pointer-events:none;
  68. }
  69. .ghcm-hidden, .ghcm-no-content:after {
  70. display:none !important;
  71. }
  72. `);
  73.  
  74. function addColors() {
  75. let sel,
  76. styles = "";
  77. headers.forEach((header, indx) => {
  78. sel = `${blocks.join(" " + header + ":after,").slice(0, -1)}`;
  79. styles += `${sel} { color:${colors[indx]} }`;
  80. });
  81. arrowColors.textContent = styles;
  82. }
  83.  
  84. function toggle(el, shifted) {
  85. if (el && !el.classList.contains("ghcm-no-content")) {
  86. el.classList.toggle(collapsed);
  87. let els;
  88. const name = el.nodeName || "",
  89. // convert H# to #
  90. level = parseInt(name.replace(/[^\d]/, ""), 10),
  91. isCollapsed = el.classList.contains(collapsed);
  92. if (shifted) {
  93. // collapse all same level anchors
  94. els = $$(`${blocks.join(" " + name + ",").slice(0, -1)}`);
  95. for (el of els) {
  96. nextHeader(el, level, isCollapsed);
  97. }
  98. } else {
  99. nextHeader(el, level, isCollapsed);
  100. }
  101. removeSelection();
  102. }
  103. }
  104.  
  105. function nextHeader(el, level, isCollapsed) {
  106. el.classList.toggle(collapsed, isCollapsed);
  107. const selector = headers.slice(0, level).join(","),
  108. name = [collapsed, "ghcm-hidden"],
  109. els = [];
  110. el = el.nextElementSibling;
  111. while (el && !el.matches(selector)) {
  112. els[els.length] = el;
  113. el = el.nextElementSibling;
  114. }
  115. if (els.length) {
  116. if (isCollapsed) {
  117. els.forEach(el => {
  118. el.classList.add("ghcm-hidden");
  119. });
  120. } else {
  121. els.forEach(el => {
  122. el.classList.remove(...name);
  123. });
  124. }
  125. }
  126. }
  127.  
  128. // show siblings of hash target
  129. function siblings(target) {
  130. let el = target.nextElementSibling,
  131. els = [target];
  132. const level = parseInt((target.nodeName || "").replace(/[^\d]/, ""), 10),
  133. selector = headers.slice(0, level - 1).join(",");
  134. while (el && !el.matches(selector)) {
  135. els[els.length] = el;
  136. el = el.nextElementSibling;
  137. }
  138. el = target.previousElementSibling;
  139. while (el && !el.matches(selector)) {
  140. els[els.length] = el;
  141. el = el.previousElementSibling;
  142. }
  143. if (els.length) {
  144. els = els.filter(el => {
  145. return el.nodeName === target.nodeName;
  146. });
  147. for (el of els) {
  148. el.classList.remove("glcm-hidden");
  149. }
  150. }
  151. nextHeader(target, level, false);
  152. }
  153.  
  154. function removeSelection() {
  155. // remove text selection - https://stackoverflow.com/a/3171348/145346
  156. const sel = window.getSelection ? window.getSelection() : document.selection;
  157. if (sel) {
  158. if (sel.removeAllRanges) {
  159. sel.removeAllRanges();
  160. } else if (sel.empty) {
  161. sel.empty();
  162. }
  163. }
  164. }
  165.  
  166. function addBinding() {
  167. document.addEventListener("click", event => {
  168. let target = event.target;
  169. const name = (target && (target.nodeName || "")).toLowerCase();
  170. if (name === "path") {
  171. target = target.closest("svg");
  172. }
  173. if (!target || target.classList.contains("anchor") ||
  174. name === "a" || name === "img" ||
  175. // add support for "pointer-events:none" applied to "anchor" in
  176. // https://github.com/StylishThemes/GitHub-FixedHeader
  177. target.classList.contains("octicon-link")) {
  178. return;
  179. }
  180. // check if element is inside a header
  181. target = event.target.closest(headers.join(","));
  182. if (target && headers.indexOf(target.nodeName || "") > -1) {
  183. // make sure the header is inside of markdown
  184. if (target.closest(blocks.slice(0, -1).join(","))) {
  185. toggle(target, event.shiftKey);
  186. }
  187. }
  188. });
  189. document.addEventListener("ghmo:container", () => {
  190. // init after a short delay to allow rendering of file list
  191. setTimeout(() => {
  192. ignoreEmptyHeaders();
  193. }, 200);
  194. });
  195. }
  196.  
  197. function checkHash() {
  198. let el, els, md;
  199. const mds = $$(blocks.slice(0, -1).join(",")),
  200. id = (window.location.hash || "").replace(/#/, "");
  201. for (md of mds) {
  202. els = $$(headers.join(","), md);
  203. if (els.length > 1) {
  204. for (el of els) {
  205. if (el && !el.classList.contains(collapsed)) {
  206. toggle(el, true);
  207. }
  208. }
  209. }
  210. }
  211. if (id) {
  212. openHash(id);
  213. }
  214. }
  215.  
  216. // open header matching hash
  217. function openHash(id) {
  218. const els = $(`#user-content-${id}`);
  219. if (els && els.classList.contains("anchor")) {
  220. let el = els.parentNode;
  221. if (el.matches(headers.join(","))) {
  222. siblings(el);
  223. document.documentElement.scrollTop = el.offsetTop;
  224. // set scrollTop a second time, in case of browser lag
  225. setTimeout(() => {
  226. document.documentElement.scrollTop = el.offsetTop;
  227. }, 500);
  228. }
  229. }
  230. }
  231.  
  232. function checkColors() {
  233. if (!colors || colors.length !== 6) {
  234. colors = [].concat(defaultColors);
  235. }
  236. }
  237.  
  238. function ignoreEmptyHeaders() {
  239. $$("a.anchor").forEach(el => {
  240. const parent = el.parentNode;
  241. if (parent && parent.matches(headers.join(",")) && !parent.nextElementSibling) {
  242. parent.classList.add("ghcm-no-content");
  243. }
  244. });
  245. }
  246.  
  247. function init() {
  248. document.querySelector("head").appendChild(arrowColors);
  249. checkColors();
  250. addColors();
  251. addBinding();
  252. ignoreEmptyHeaders();
  253. if (startCollapsed) {
  254. checkHash();
  255. }
  256. }
  257.  
  258. function $(selector, el) {
  259. return (el || document).querySelector(selector);
  260. }
  261.  
  262. function $$(selectors, el) {
  263. return [...(el || document).querySelectorAll(selectors)];
  264. }
  265.  
  266. // Add GM options
  267. GM_registerMenuCommand("Set collapse markdown state", () => {
  268. const val = prompt(
  269. "Set initial state to (c)ollapsed or (e)xpanded (first letter necessary):",
  270. startCollapsed ? "collapsed" : "expanded"
  271. );
  272. if (val !== null) {
  273. startCollapsed = /^c/i.test(val);
  274. GM_setValue("ghcm-collapsed", startCollapsed);
  275. console.log(
  276. `GitHub Collapse Markdown: Headers will ${startCollapsed ? "be" : "not be"} initially collapsed`
  277. );
  278. }
  279. });
  280.  
  281. GM_registerMenuCommand("Set collapse markdown colors", () => {
  282. let val = prompt("Set header arrow colors:", JSON.stringify(colors));
  283. if (val !== null) {
  284. // allow pasting in a JSON format
  285. try {
  286. val = JSON.parse(val);
  287. if (val && val.length === 6) {
  288. colors = val;
  289. GM_setValue("ghcm-colors", colors);
  290. console.log("GitHub Collapse Markdown: colors set to", colors);
  291. addColors();
  292. return;
  293. }
  294. console.error(
  295. "GitHub Collapse Markdown: invalid color definition (6 colors)",
  296. val
  297. );
  298. // reset colors to default (in case colors variable is corrupted)
  299. checkColors();
  300. } catch (err) {
  301. console.error("GitHub Collapse Markdown: invalid JSON");
  302. }
  303. }
  304. });
  305.  
  306. init();
  307.  
  308. })();