// ==UserScript== // @name GitHub Table of Contents // @version 2.1.6 // @description A userscript that adds a table of contents to readme & wiki pages // @license MIT // @author Rob Garrison // @namespace https://github.com/Mottie // @match https://github.com/* // @match https://gist.github.com/* // @run-at document-idle // @grant GM_registerMenuCommand // @grant GM.registerMenuCommand // @grant GM_getValue // @grant GM.getValue // @grant GM_setValue // @grant GM.setValue // @grant GM_addStyle // @grant GM.addStyle // @require https://greasyfork.org/scripts/28721-mutations/code/mutations.js?version=1108163 // @require https://greasyfork.org/scripts/398877-utils-js/code/utilsjs.js?version=1079637 // @require https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js?updated=20180103 // @icon https://github.githubassets.com/pinned-octocat.svg // @updateURL https://raw.githubusercontent.com/Mottie/GitHub-userscripts/master/github-toc.user.js // @downloadURL https://raw.githubusercontent.com/Mottie/GitHub-userscripts/master/github-toc.user.js // @supportURL https://github.com/Mottie/GitHub-userscripts/issues // ==/UserScript== /* global $ $$ on addClass removeClass */ (async () => { "use strict"; const defaults = { title: "Table of Contents", // popup title top: "64px", // popup top position when reset left: "auto", // popup left position when reset right: "10px", // popup right position when reset headerPad: "48px", // padding added to header when TOC is collapsed headerSelector: [".header", ".Header", ".header-logged-out > div"], wrapperSelector: "#wiki-body, #readme, [id^='file-'][id$='-md']", headerWrap: ".js-header-wrapper", toggle: "g+t", // keyboard toggle shortcut restore: "g+r", // keyboard reset popup position shortcut delay: 1000, // ms between keyboard shortcuts }; GM.addStyle(` /* z-index > 1000 to be above the */ .ghus-toc { position:fixed; z-index:1001; min-width:200px; min-height:100px; top:${defaults.top}; right:${defaults.right}; resize:both; overflow:hidden; padding: 0 3px 38px 0; margin:0; } .ghus-toc h3 { cursor:move; } .ghus-toc-title { padding-left:20px; } /* icon toggles TOC container & subgroups */ .ghus-toc .ghus-toc-icon { vertical-align:baseline; } .ghus-toc h3 .ghus-toc-icon, .ghus-toc li.collapsible .ghus-toc-icon { cursor:pointer; } .ghus-toc .ghus-toc-toggle { position:absolute; width:28px; height:38px; top:0px; left:0px; } .ghus-toc .ghus-toc-toggle svg { margin-top:10px; margin-left:9px; } .ghus-toc .ghus-toc-docs { float:right; } /* move collapsed TOC to top right corner */ .ghus-toc.collapsed { width:30px !important; height:34px !important; min-width:auto; min-height:auto; overflow:hidden; top:16px !important; left:auto !important; right:10px !important; margin:0; padding:0; border:1px solid rgba(128, 128, 128, 0.5); border-radius:3px; resize:none; } .ghus-toc.collapsed > h3 { cursor:pointer; padding-top:5px; border:none; background:#222; color:#ddd; } .ghus-toc.collapsed .ghus-toc-docs, .ghus-toc.collapsed .ghus-toc-title { display:none; } .ghus-toc:not(.ghus-toc-hidden).collapsed + .Header { padding-right: ${defaults.headerPad} !important; } /* move header text out-of-view when collapsed */ .ghus-toc.collapsed > h3 svg { margin-top:6px; } .ghus-toc-hidden, .ghus-toc.collapsed .boxed-group-inner { display:none; } .ghus-toc .boxed-group-inner { width:100%; height:100%; overflow-x:hidden; overflow-y:auto; margin-bottom:50px; } .ghus-toc ul { list-style:none; } .ghus-toc li { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; position:relative; } .ghus-toc .ghus-toc-h1 { padding-left:15px; } .ghus-toc .ghus-toc-h2 { padding-left:30px; } .ghus-toc .ghus-toc-h3 { padding-left:45px; } .ghus-toc .ghus-toc-h4 { padding-left:60px; } .ghus-toc .ghus-toc-h5 { padding-left:75px; } .ghus-toc .ghus-toc-h6 { padding-left:90px; } /* anchor collapsible icon */ .ghus-toc li.collapsible .ghus-toc-icon:before { content:' '; position:absolute; width:20px; height:20px; display:inline-block; left:-16px; top:-14px; background: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGNsYXNzPSdvY3RpY29uJyBoZWlnaHQ9JzE0JyB2aWV3Qm94PScwIDAgMTIgMTYnPjxwYXRoIGQ9J00wIDVsNiA2IDYtNkgweic+PC9wYXRoPjwvc3ZnPg==) left center no-repeat; } .ghus-toc li.collapsible.collapsed .ghus-toc-icon:before { -webkit-transform:rotate(-90deg); transform:rotate(-90deg); top:-20px; left:-20px; } .ghus-toc-icon svg, .ghus-toc-docs svg { pointer-events:none; } .ghus-toc-no-selection { -webkit-user-select:none !important; -moz-user-select:none !important; user-select:none !important; } /* prevent google translate from breaking links */ .ghus-toc li a font { pointer-events:none; } `); let tocInit = false; // modifiable title let title = await GM.getValue("github-toc-title", defaults.title); const container = document.createElement("div"); // keyboard shortcuts const keyboard = { timer: null, lastKey: null }; // drag variables const drag = { el: null, elmX: 0, elmY: 0, time: 0, unsel: null }; const regex = { quote: /'/g, doubleQuote: /"/g, number: /\d/, toggle: /\+/g, header: /ghus-toc-h\d/, collapsible: /collapsible-(\d+)/, ignore: /(input|textarea)/i, }; const stopPropag = event => { event.preventDefault(); event.stopPropagation(); }; // drag code adapted from http://jsfiddle.net/tovic/Xcb8d/light/ function dragInit(event) { if (!container.classList.contains("collapsed")) { drag.el = container; drag.elmX = event.pageX - drag.el.offsetLeft; drag.elmY = event.pageY - drag.el.offsetTop; selectionToggle(true); } else { drag.el = null; } drag.time = new Date().getTime() + 500; } function dragMove(event) { if (drag.el !== null) { drag.el.style.left = `${event.pageX - drag.elmX}px`; drag.el.style.top = `${event.pageY - drag.elmY}px`; drag.el.style.right = "auto"; } } async function dragStop(event) { if (drag.el !== null) { dragSave(); selectionToggle(); } drag.el = null; if (event.target === container) { // save container size on mouseup await GM.setValue( "github-toc-size", [container.clientWidth, container.clientHeight] ); } } async function dragSave(restore) { let adjLeft = null; let top = null; let val = null; if (restore) { // position restore (reset) popup to default position setPosition(defaults.left, defaults.top, defaults.right); } else { // Adjust saved left position to be measured from the center of the window // See issue #102 const winHalf = window.innerWidth / 2; const left = winHalf - parseInt(container.style.left, 10); adjLeft = left * (left > winHalf ? 1 : -1); top = parseInt(container.style.top, 10); val = [adjLeft, top]; } drag.elmX = adjLeft; drag.elmY = top; await GM.setValue("github-toc-location", val); } function resize(_, left = drag.elmX, top = drag.elmY) { if (left !== null) { drag.elmX = left; drag.elmY = top; setPosition(((window.innerWidth / 2) + left) + "px", top + "px"); } } function setPosition(left, top, right = "auto") { container.style.left = left; container.style.right = right; container.style.top = top; } async function setSize() { const size = await GM.getValue("github-toc-size", [250, 250]); container.style.width = `${size[0]}px`; container.style.height = `${size[1]}px`; } // stop text selection while dragging function selectionToggle(disable) { const body = $("body"); if (disable) { // save current "unselectable" value drag.unsel = body.getAttribute("unselectable"); body.setAttribute("unselectable", "on"); body.classList.add("ghus-toc-no-selection"); on(body, "onselectstart", stopPropag); } else { if (drag.unsel) { body.setAttribute("unselectable", drag.unsel); } body.classList.remove("ghus-toc-no-selection"); off(body, "onselectstart", stopPropag); } removeSelection(); } function removeSelection() { // remove text selection - http://stackoverflow.com/a/3171348/145346 const sel = window.getSelection ? window.getSelection() : document.selection; if (sel) { if (sel.removeAllRanges) { sel.removeAllRanges(); } else if (sel.empty) { sel.empty(); } } } async function tocShow() { container.classList.remove("collapsed"); await GM.setValue("github-toc-hidden", false); } async function tocHide() { container.classList.add("collapsed"); await GM.setValue("github-toc-hidden", true); } function tocToggle() { // don't toggle content on long clicks if (drag.time > new Date().getTime()) { if (container.classList.contains("collapsed")) { tocShow(); } else { tocHide(); } } } // hide TOC entirely, if no rendered markdown detected function tocView(isVisible) { const toc = $(".ghus-toc"); if (toc) { toc.classList.toggle("ghus-toc-hidden", !isVisible); } } function tocAdd() { if (!tocInit) { return; } const wrapper = $(defaults.wrapperSelector); if (wrapper) { let indx, header, anchor, txt; let content = ""; tocView(true); listCollapsible(); } else { tocView(); } } else { tocView(); } } function listCollapsible() { let indx, el, next, count, num, group; const els = $$("li", container); const len = els.length; for (indx = 0; indx < len; indx++) { count = 0; group = []; el = els[indx]; next = el?.nextElementSibling; if (next) { num = el.className.match(regex.number)[0]; while (next && !next.classList.contains("ghus-toc-h" + num)) { if (next.className.match(regex.number)[0] > num) { count++; group[group.length] = next; } next = next.nextElementSibling; } if (count > 0) { el.className += " collapsible collapsible-" + indx; addClass(group, "ghus-toc-childof-" + indx); } } } group = []; } function toggleChildrenHandler(event) { // Allow doc link to work if (event.target.nodeName === "A") { return; } stopPropag(event); // click on icon, then target LI parent let els, name, indx; const el = event.target.closest("li"); const collapse = el?.classList.contains("collapsed"); if (event.target.classList.contains("ghus-toc-icon")) { if (event.shiftKey) { name = el.className.match(regex.header); els = name ? $$("." + name, container) : []; indx = els.length; while (indx--) { collapseChildren(els[indx], collapse); } } else { collapseChildren(el, collapse); } removeSelection(); } } function collapseChildren(el, collapse) { const name = el?.className.match(regex.collapsible); const children = name ? $$(".ghus-toc-childof-" + name[1], container) : null; if (children) { if (collapse) { el.classList.remove("collapsed"); removeClass(children, "ghus-toc-hidden"); } else { el.classList.add("collapsed"); addClass(children, "ghus-toc-hidden"); } } } // keyboard shortcuts // GitHub hotkeys are set up to only go to a url, so rolling our own function keyboardCheck(event) { clearTimeout(keyboard.timer); // use "g+t" to toggle the panel; "g+r" to reset the position // keypress may be needed for non-alphanumeric keys const tocToggleKeys = defaults.toggle.split("+"); const tocReset = defaults.restore.split("+"); const key = String.fromCharCode(event.which).toLowerCase(); const panelHidden = container.classList.contains("collapsed"); // press escape to close the panel if (event.which === 27 && !panelHidden) { tocHide(); return; } // prevent opening panel while typing in comments if (regex.ignore.test(document.activeElement.nodeName)) { return; } // toggle TOC (g+t) if (keyboard.lastKey === tocToggleKeys[0] && key === tocToggleKeys[1]) { if (panelHidden) { tocShow(); } else { tocHide(); } } // reset TOC window position (g+r) if (keyboard.lastKey === tocReset[0] && key === tocReset[1]) { container.setAttribute("style", ""); setSize(); dragSave(true); } keyboard.lastKey = key; keyboard.timer = setTimeout(() => { keyboard.lastKey = null; }, defaults.delay); } async function init() { // there is no ".header" on github.com/contact; and some other pages const header = $([...defaults.headerSelector, defaults.headerWrap].join(",")); if (!header || tocInit) { return; } // insert TOC after header const location = await GM.getValue("github-toc-location", null); // restore last position resize(null, ...(location ? location : [null])); // TOC saved state const hidden = await GM.getValue("github-toc-hidden", false); setSize(); container.className = "ghus-toc boxed-group wiki-pages-box readability-sidebar" + (hidden ? " collapsed" : ""); container.setAttribute("role", "navigation"); container.setAttribute("unselectable", "on"); container.setAttribute("index", "0"); container.innerHTML = `

${title}

`; // add container const el = $(defaults.headerSelector.join(",")); el.parentElement.insertBefore(container, el); // make draggable on($("h3", container), "mousedown", dragInit); on(document, "mousemove", dragMove); on(document, "mouseup", dragStop); // toggle TOC on($(".ghus-toc-icon", container), "mouseup", tocToggle); on($("h3", container), "dblclick", tocHide); // prevent container content selection on(container, "onselectstart", stopPropag); on(container, "click", toggleChildrenHandler); // keyboard shortcuts on(document, "keydown", keyboardCheck); // keep window relative to middle on resize on(window, "resize", resize); tocInit = true; tocAdd(); } // Add GM options GM.registerMenuCommand("Set Table of Contents Title", async () => { title = prompt("Table of Content Title:", title); await GM.setValue("github-toc-title", title); $("h3 .ghus-toc-title", container).textContent = title; }); on(document, "ghmo:container", tocAdd); on(document, "ghmo:preview", tocAdd); init(); })();