Greasy Fork is available in English.

GitLab Collapse Markdown

A userscript that collapses markdown headers

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