GitHub Collapse Markdown

A userscript that collapses markdown headers

目前為 2021-01-31 提交的版本,檢視 最新版本

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