GitHub Table of Contents

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

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

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址