GitHub Table of Contents

A userscript that adds a table of contents to readme & wiki pages

当前为 2020-08-09 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name GitHub Table of Contents
  3. // @version 2.0.5
  4. // @description A userscript that adds a table of contents to readme & wiki pages
  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_registerMenuCommand
  12. // @grant GM_getValue
  13. // @grant GM.getValue
  14. // @grant GM_setValue
  15. // @grant GM.setValue
  16. // @grant GM_addStyle
  17. // @grant GM.addStyle
  18. // @require https://greasyfork.org/scripts/28721-mutations/code/mutations.js?version=666427
  19. // @require https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js?updated=20180103
  20. // @icon https://github.githubassets.com/pinned-octocat.svg
  21. // ==/UserScript==
  22. (async () => {
  23. "use strict";
  24.  
  25. const defaults = {
  26. title: "Table of Contents", // popup title
  27. top: "64px", // popup top position when reset
  28. left: "auto", // popup left position when reset
  29. right: "10px", // popup right position when reset
  30. headerPad: "48px", // padding added to header when TOC is collapsed
  31. headerSelector: [".header", ".Header"],
  32. headerWrap: ".js-header-wrapper",
  33. toggle: "g+t", // keyboard toggle shortcut
  34. restore: "g+r", // keyboard reset popup position shortcut
  35. delay: 1000, // ms between keyboard shortcuts
  36. };
  37.  
  38. GM.addStyle(`
  39. /* z-index > 1000 to be above the */
  40. .ghus-toc { position:fixed; z-index:1001; min-width:200px; top:${defaults.top};
  41. right:${defaults.right}; }
  42. .ghus-toc h3 { cursor:move; }
  43. .ghus-toc-title { padding-left:20px; }
  44. /* icon toggles TOC container & subgroups */
  45. .ghus-toc .ghus-toc-icon { vertical-align:baseline; }
  46. .ghus-toc h3 .ghus-toc-icon, .ghus-toc li.collapsible .ghus-toc-icon { cursor:pointer; }
  47. .ghus-toc .ghus-toc-toggle { position:absolute; width:28px; height:38px; top:0px; left:0px; }
  48. .ghus-toc .ghus-toc-toggle svg { margin-top:10px; margin-left:9px; }
  49. .ghus-toc .ghus-toc-docs { float:right; }
  50. /* move collapsed TOC to top right corner */
  51. .ghus-toc.collapsed {
  52. width:30px; height:30px; min-width:auto; overflow:hidden; top:16px !important; left:auto !important;
  53. right:10px !important; border:1px solid rgba(128, 128, 128, 0.5); border-radius:3px;
  54. }
  55. .ghus-toc.collapsed > h3 { cursor:pointer; padding-top:5px; border:none; background:#222; color:#ddd; }
  56. .ghus-toc.collapsed .ghus-toc-docs { display:none; }
  57. .ghus-toc:not(.ghus-toc-hidden).collapsed + .Header { padding-right: ${defaults.headerPad} !important; }
  58. /* move header text out-of-view when collapsed */
  59. .ghus-toc.collapsed > h3 svg { margin-top:6px; }
  60. .ghus-toc-hidden, .ghus-toc.collapsed .boxed-group-inner,
  61. .ghus-toc li:not(.collapsible) .ghus-toc-icon { display:none; }
  62. .ghus-toc .boxed-group-inner { max-width:250px; max-height:400px; overflow-y:auto; overflow-x:hidden; }
  63. .ghus-toc ul { list-style:none; }
  64. .ghus-toc li { max-width:230px; white-space:nowrap; overflow-x:hidden; text-overflow:ellipsis; }
  65. .ghus-toc .ghus-toc-h1 { padding-left:15px; }
  66. .ghus-toc .ghus-toc-h2 { padding-left:30px; }
  67. .ghus-toc .ghus-toc-h3 { padding-left:45px; }
  68. .ghus-toc .ghus-toc-h4 { padding-left:60px; }
  69. .ghus-toc .ghus-toc-h5 { padding-left:75px; }
  70. .ghus-toc .ghus-toc-h6 { padding-left:90px; }
  71. /* anchor collapsible icon */
  72. .ghus-toc li.collapsible .ghus-toc-icon {
  73. width:16px; height:10px; display:inline-block; margin-left:-16px;
  74. background: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGNsYXNzPSdvY3RpY29uJyBoZWlnaHQ9JzE0JyB2aWV3Qm94PScwIDAgMTIgMTYnPjxwYXRoIGQ9J00wIDVsNiA2IDYtNkgweic+PC9wYXRoPjwvc3ZnPg==) left center no-repeat;
  75. }
  76. /* on rotate, height becomes width, so this is keeping things lined up */
  77. .ghus-toc li.collapsible.collapsed .ghus-toc-icon { -webkit-transform:rotate(-90deg); transform:rotate(-90deg); height:10px; width:12px; margin-right:2px; }
  78. .ghus-toc-icon svg, .ghus-toc-docs svg { pointer-events:none; }
  79. .ghus-toc-no-selection { -webkit-user-select:none !important; -moz-user-select:none !important; user-select:none !important; }
  80. /* prevent google translate from breaking links */
  81. .ghus-toc li a font { pointer-events:none; }
  82. `);
  83.  
  84. let tocInit = false;
  85.  
  86. // modifiable title
  87. let title = await GM.getValue("github-toc-title", defaults.title);
  88.  
  89. const container = document.createElement("div");
  90. const useClient = !!document.all;
  91.  
  92. // keyboard shortcuts
  93. const keyboard = {
  94. timer: null,
  95. lastKey: null
  96. };
  97.  
  98. // drag variables
  99. const drag = {
  100. el: null,
  101. elmX: 0,
  102. elmY: 0,
  103. time: 0,
  104. unsel: null
  105. };
  106.  
  107. const stopPropag = event => {
  108. event.preventDefault();
  109. event.stopPropagation();
  110. };
  111.  
  112. // drag code adapted from http://jsfiddle.net/tovic/Xcb8d/light/
  113. function dragInit(event) {
  114. if (!container.classList.contains("collapsed")) {
  115. const x = useClient ? window.event.clientX : event.pageX;
  116. const y = useClient ? window.event.clientY : event.pageY;
  117. drag.el = container;
  118. drag.elmX = x - drag.el.offsetLeft;
  119. drag.elmY = y - drag.el.offsetTop;
  120. selectionToggle(true);
  121. } else {
  122. drag.el = null;
  123. }
  124. drag.time = new Date().getTime() + 500;
  125. }
  126.  
  127. function dragMove(event) {
  128. if (drag.el !== null) {
  129. const x = useClient ? window.event.clientX : event.pageX;
  130. const y = useClient ? window.event.clientY : event.pageY;
  131. drag.el.style.left = (x - drag.elmX) + "px";
  132. drag.el.style.top = (y - drag.elmY) + "px";
  133. drag.el.style.right = "auto";
  134. }
  135. }
  136.  
  137. function dragStop() {
  138. if (drag.el !== null) {
  139. dragSave();
  140. selectionToggle();
  141. }
  142. drag.el = null;
  143. }
  144.  
  145. async function dragSave(restore) {
  146. let adjLeft = null;
  147. let top = null;
  148. let val = null;
  149. if (restore) {
  150. // position restore (reset) popup to default position
  151. setPosition(defaults.left, defaults.top, defaults.right);
  152. } else {
  153. // Adjust saved left position to be measured from the center of the window
  154. // See issue #102
  155. const winHalf = window.innerWidth / 2;
  156. const left = winHalf - parseInt(container.style.left, 10);
  157. adjLeft = left * (left > winHalf ? 1 : -1);
  158. top = parseInt(container.style.top, 10);
  159. val = [adjLeft, top];
  160. }
  161. drag.elmX = adjLeft;
  162. drag.elmY = top;
  163. await GM.setValue("github-toc-location", val);
  164. }
  165.  
  166. function resize(_, left = drag.elmX, top = drag.elmY) {
  167. if (left !== null) {
  168. drag.elmX = left;
  169. drag.elmY = top;
  170. setPosition(((window.innerWidth / 2) + left) + "px", top + "px");
  171. }
  172. }
  173.  
  174. function setPosition(left, top, right = "auto") {
  175. container.style.left = left;
  176. container.style.right = right;
  177. container.style.top = top;
  178. }
  179.  
  180. // stop text selection while dragging
  181. function selectionToggle(disable) {
  182. const body = $("body");
  183. if (disable) {
  184. // save current "unselectable" value
  185. drag.unsel = body.getAttribute("unselectable");
  186. body.setAttribute("unselectable", "on");
  187. body.classList.add("ghus-toc-no-selection");
  188. on(body, "onselectstart", stopPropag);
  189. } else {
  190. if (drag.unsel) {
  191. body.setAttribute("unselectable", drag.unsel);
  192. }
  193. body.classList.remove("ghus-toc-no-selection");
  194. body.removeEventListener("onselectstart", stopPropag);
  195. }
  196. removeSelection();
  197. }
  198.  
  199. function removeSelection() {
  200. // remove text selection - http://stackoverflow.com/a/3171348/145346
  201. const sel = window.getSelection ? window.getSelection() : document.selection;
  202. if (sel) {
  203. if (sel.removeAllRanges) {
  204. sel.removeAllRanges();
  205. } else if (sel.empty) {
  206. sel.empty();
  207. }
  208. }
  209. }
  210.  
  211. async function tocShow() {
  212. container.classList.remove("collapsed");
  213. await GM.setValue("github-toc-hidden", false);
  214. }
  215.  
  216. async function tocHide() {
  217. container.classList.add("collapsed");
  218. await GM.setValue("github-toc-hidden", true);
  219. }
  220.  
  221. function tocToggle() {
  222. // don't toggle content on long clicks
  223. if (drag.time > new Date().getTime()) {
  224. if (container.classList.contains("collapsed")) {
  225. tocShow();
  226. } else {
  227. tocHide();
  228. }
  229. }
  230. }
  231. // hide TOC entirely, if no rendered markdown detected
  232. function tocView(isVisible) {
  233. const toc = $(".ghus-toc");
  234. if (toc) {
  235. toc.classList.toggle("ghus-toc-hidden", !isVisible);
  236. }
  237. }
  238.  
  239. function tocAdd() {
  240. if (!tocInit) {
  241. return;
  242. }
  243. const wrapper = $("#wiki-body, #readme");
  244. if (wrapper) {
  245. let indx, header, anchor, txt;
  246. let content = "<ul>";
  247. const anchors = $$(".markdown-body .anchor", wrapper);
  248. const len = anchors.length;
  249. if (len > 1) {
  250. for (indx = 0; indx < len; indx++) {
  251. anchor = anchors[indx];
  252. if (anchor.parentElement) {
  253. header = anchor.parentElement;
  254. // replace single & double quotes with right angled quotes
  255. txt = header.textContent.trim().replace(/'/g, "&#8217;").replace(/"/g, "&#8221;");
  256. content += `
  257. <li class="ghus-toc-${header.nodeName.toLowerCase()}">
  258. <span class="ghus-toc-icon octicon ghd-invert"></span>
  259. <a href="${anchor.hash}" title="${txt}">${txt}</a>
  260. </li>
  261. `;
  262. }
  263. }
  264. $(".boxed-group-inner", container).innerHTML = content + "</ul>";
  265. tocView(true);
  266. listCollapsible();
  267. } else {
  268. tocView();
  269. }
  270. } else {
  271. tocView();
  272. }
  273. }
  274.  
  275. function listCollapsible() {
  276. let indx, el, next, count, num, group;
  277. const els = $$("li", container);
  278. const len = els.length;
  279. const regex = /\d/;
  280. for (indx = 0; indx < len; indx++) {
  281. count = 0;
  282. group = [];
  283. el = els[indx];
  284. next = el && el.nextElementSibling;
  285. if (next) {
  286. num = el.className.match(regex)[0];
  287. while (next && !next.classList.contains("ghus-toc-h" + num)) {
  288. if (next.className.match(regex)[0] > num) {
  289. count++;
  290. group[group.length] = next;
  291. }
  292. next = next.nextElementSibling;
  293. }
  294. if (count > 0) {
  295. el.className += " collapsible collapsible-" + indx;
  296. addClass(group, "ghus-toc-childof-" + indx);
  297. }
  298. }
  299. }
  300. group = [];
  301. on(container, "click", event => {
  302. // Allow doc link to work
  303. if (event.target.nodeName === "A") {
  304. return;
  305. }
  306. stopPropag(event);
  307. // click on icon, then target LI parent
  308. let els, name, indx;
  309. const el = event.target.parentElement;
  310. const collapse = el.classList.contains("collapsed");
  311. if (event.target.classList.contains("ghus-toc-icon")) {
  312. if (event.shiftKey) {
  313. name = el.className.match(/ghus-toc-h\d/);
  314. els = name ? $$("." + name, container) : [];
  315. indx = els.length;
  316. while (indx--) {
  317. collapseChildren(els[indx], collapse);
  318. }
  319. } else {
  320. collapseChildren(el, collapse);
  321. }
  322. removeSelection();
  323. }
  324. });
  325. }
  326.  
  327. function collapseChildren(el, collapse) {
  328. const name = el && el.className.match(/collapsible-(\d+)/);
  329. const children = name ? $$(".ghus-toc-childof-" + name[1], container) : null;
  330. if (children) {
  331. if (collapse) {
  332. el.classList.remove("collapsed");
  333. removeClass(children, "ghus-toc-hidden");
  334. } else {
  335. el.classList.add("collapsed");
  336. addClass(children, "ghus-toc-hidden");
  337. }
  338. }
  339. }
  340.  
  341. // keyboard shortcuts
  342. // GitHub hotkeys are set up to only go to a url, so rolling our own
  343. function keyboardCheck(event) {
  344. clearTimeout(keyboard.timer);
  345. // use "g+t" to toggle the panel; "g+r" to reset the position
  346. // keypress may be needed for non-alphanumeric keys
  347. const tocToggleKeys = defaults.toggle.split("+");
  348. const tocReset = defaults.restore.split("+");
  349. const key = String.fromCharCode(event.which).toLowerCase();
  350. const panelHidden = container.classList.contains("collapsed");
  351.  
  352. // press escape to close the panel
  353. if (event.which === 27 && !panelHidden) {
  354. tocHide();
  355. return;
  356. }
  357. // prevent opening panel while typing in comments
  358. if (/(input|textarea)/i.test(document.activeElement.nodeName)) {
  359. return;
  360. }
  361. // toggle TOC (g+t)
  362. if (keyboard.lastKey === tocToggleKeys[0] && key === tocToggleKeys[1]) {
  363. if (panelHidden) {
  364. tocShow();
  365. } else {
  366. tocHide();
  367. }
  368. }
  369. // reset TOC window position (g+r)
  370. if (keyboard.lastKey === tocReset[0] && key === tocReset[1]) {
  371. container.setAttribute("style", "");
  372. dragSave(true);
  373. }
  374. keyboard.lastKey = key;
  375. keyboard.timer = setTimeout(() => {
  376. keyboard.lastKey = null;
  377. }, defaults.delay);
  378. }
  379.  
  380. async function init() {
  381. // there is no ".header" on github.com/contact; and some other pages
  382. const header = $([...defaults.headerSelector, defaults.headerWrap].join(","));
  383. if (!header || tocInit) {
  384. return;
  385. }
  386. // insert TOC after header
  387. const location = await GM.getValue("github-toc-location", null);
  388. // restore last position
  389. resize(null, ...(location ? location : [null]));
  390.  
  391. // TOC saved state
  392. const hidden = await GM.getValue("github-toc-hidden", false);
  393. container.className = "ghus-toc boxed-group wiki-pages-box readability-sidebar" + (hidden ? " collapsed" : "");
  394. container.setAttribute("role", "navigation");
  395. container.setAttribute("unselectable", "on");
  396. container.setAttribute("index", "0");
  397. container.innerHTML = `
  398. <h3 class="js-wiki-toggle-collapse wiki-auxiliary-content" data-hotkey="${defaults.toggle.replace(/\+/g, " ")}">
  399. <span class="ghus-toc-toggle ghus-toc-icon">
  400. <svg class="octicon" height="14" width="14" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 16 12">
  401. <path d="M2 13c0 .6 0 1-.6 1H.6c-.6 0-.6-.4-.6-1s0-1 .6-1h.8c.6 0 .6.4.6 1zm2.6-9h6.8c.6 0 .6-.4.6-1s0-1-.6-1H4.6C4 2 4 2.4 4 3s0 1 .6 1zM1.4 7H.6C0 7 0 7.4 0 8s0 1 .6 1h.8C2 9 2 8.6 2 8s0-1-.6-1zm0-5H.6C0 2 0 2.4 0 3s0 1 .6 1h.8C2 4 2 3.6 2 3s0-1-.6-1zm10 5H4.6C4 7 4 7.4 4 8s0 1 .6 1h6.8c.6 0 .6-.4.6-1s0-1-.6-1zm0 5H4.6c-.6 0-.6.4-.6 1s0 1 .6 1h6.8c.6 0 .6-.4.6-1s0-1-.6-1z"/>
  402. </svg>
  403. </span>
  404. <span class="ghus-toc-title">${title}</span>
  405. <a class="ghus-toc-docs tooltipped tooltipped-w" aria-label="Go to documentation" href="https://github.com/Mottie/GitHub-userscripts/wiki/GitHub-table-of-contents">
  406. <svg class="octicon" xmlns="http://www.w3.org/2000/svg" height="16" width="14" viewBox="0 0 16 14">
  407. <path d="M6 10h2v2H6V10z m4-3.5c0 2.14-2 2.5-2 2.5H6c0-0.55 0.45-1 1-1h0.5c0.28 0 0.5-0.22 0.5-0.5v-1c0-0.28-0.22-0.5-0.5-0.5h-1c-0.28 0-0.5 0.22-0.5 0.5v0.5H4c0-1.5 1.5-3 3-3s3 1 3 2.5zM7 2.3c3.14 0 5.7 2.56 5.7 5.7S10.14 13.7 7 13.7 1.3 11.14 1.3 8s2.56-5.7 5.7-5.7m0-1.3C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7S10.86 1 7 1z" />
  408. </svg>
  409. </a>
  410. </h3>
  411. <div class="boxed-group-inner wiki-auxiliary-content wiki-auxiliary-content-no-bg"></div>
  412. `;
  413.  
  414. // add container
  415. const el = $(defaults.headerSelector.join(","));
  416. el.parentElement.insertBefore(container, el);
  417.  
  418. // make draggable
  419. on($("h3", container), "mousedown", dragInit);
  420. on(document, "mousemove", dragMove);
  421. on(document, "mouseup", dragStop);
  422. // toggle TOC
  423. on($(".ghus-toc-icon", container), "mouseup", tocToggle);
  424. on($("h3", container), "dblclick", tocHide);
  425. // prevent container content selection
  426. on(container, "onselectstart", stopPropag);
  427. // keyboard shortcuts
  428. on(document, "keydown", keyboardCheck);
  429. // keep window relative to middle on resize
  430. on(window, "resize", resize);
  431.  
  432. tocInit = true;
  433. tocAdd();
  434. }
  435.  
  436. function $(str, el) {
  437. return (el || document).querySelector(str);
  438. }
  439.  
  440. function $$(str, el) {
  441. return Array.from((el || document).querySelectorAll(str));
  442. }
  443.  
  444. function on(el, name, handler) {
  445. el.addEventListener(name, handler);
  446. }
  447.  
  448. function addClass(els, name) {
  449. let indx;
  450. const len = els.length;
  451. for (indx = 0; indx < len; indx++) {
  452. els[indx].classList.add(name);
  453. }
  454. }
  455.  
  456. function removeClass(els, name) {
  457. let indx;
  458. const len = els.length;
  459. for (indx = 0; indx < len; indx++) {
  460. els[indx].classList.remove(name);
  461. }
  462. }
  463.  
  464. // Add GM options
  465. GM.registerMenuCommand("Set Table of Contents Title", async () => {
  466. title = prompt("Table of Content Title:", title);
  467. await GM.setValue("github-toc-title", title);
  468. $("h3 .ghus-toc-title", container).textContent = title;
  469. });
  470.  
  471. on(document, "ghmo:container", tocAdd);
  472. on(document, "ghmo:preview", tocAdd);
  473. init();
  474.  
  475. })();