// ==UserScript== // @name GitHub TOC // @version 1.0.0 // @description A userscript that adds a table of contents to readme & wiki pages // @license https://creativecommons.org/licenses/by-sa/4.0/ // @namespace http://github.com/Mottie // @include https://github.com/* // @run-at document-idle // @grant GM_registerMenuCommand // @grant GM_getValue // @grant GM_setValue // @grant GM_addStyle // @author Rob Garrison // @downloadURL none // ==/UserScript== /* global GM_registerMenuCommand, GM_getValue, GM_setValue, GM_addStyle */ /*jshint unused:true */ (function() { "use strict"; GM_addStyle([ ".github-toc { position:fixed; z-index:75; min-width:200px; top:55px; right:10px; }", ".github-toc h3 { cursor:move; }", // icon toggles TOC container & subgroups ".github-toc h3 svg, .github-toc li.collapsible .github-toc-icon { cursor:pointer; }", // move collapsed TOC to top right corner ".github-toc.collapsed {", "width:30px; height:30px; min-width:auto; overflow:hidden; top:10px !important; left:auto !important;", "right:10px !important; border:1px solid #d8d8d8; border-radius:3px;", "}", ".github-toc.collapsed > h3 { cursor:pointer; padding-top:5px; border:none; }", // move header text out-of-view when collapsed ".github-toc.collapsed > h3 svg { margin-bottom: 10px; }", ".github-toc-hidden, .github-toc.collapsed .boxed-group-inner,", ".github-toc li:not(.collapsible) .github-toc-icon { display:none; }", ".github-toc .boxed-group-inner { max-width:250px; max-height:400px; overflow-y:auto; overflow-x:hidden; }", ".github-toc ul { list-style:none; }", ".github-toc li { max-width:230px; white-space:nowrap; overflow-x:hidden; text-overflow:ellipsis; }", ".github-toc .github-toc-h1 { padding-left:15px; }", ".github-toc .github-toc-h2 { padding-left:30px; }", ".github-toc .github-toc-h3 { padding-left:45px; }", ".github-toc .github-toc-h4 { padding-left:60px; }", ".github-toc .github-toc-h5 { padding-left:75px; }", ".github-toc .github-toc-h6 { padding-left:90px; }", // anchor collapsible icon ".github-toc li.collapsible .github-toc-icon {", "width:16px; height:16px; display:inline-block; margin-left:-16px;", "background: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGNsYXNzPSdvY3RpY29uJyBoZWlnaHQ9JzE0JyB2aWV3Qm94PScwIDAgMTIgMTYnPjxwYXRoIGQ9J00wIDVsNiA2IDYtNkgweic+PC9wYXRoPjwvc3ZnPg==) left center no-repeat;", "}", // on rotate, height becomes width, so this is keeping things lined up ".github-toc li.collapsible.collapsed .github-toc-icon { -webkit-transform:rotate(-90deg); transform:rotate(-90deg); height:10px; width:12px; margin-right:2px; }", ".github-toc-no-selection { -webkit-user-select:none !important; -moz-user-select:none !important; user-select:none !important; }" ].join("")); // modifiable title var title = GM_getValue("github-toc-title", "Table of Contents"), container = document.createElement("div"), busy = false, // keyboard shortcuts keyboard = { toggle : "g+t", restore : "g+r", timer : null, lastKey : null, delay : 1000 // ms between keyboard shortcuts }, // drag variables drag = { el : null, pos : [ 0, 0 ], elm : [ 0, 0 ], time : 0, unsel: null }, // drag code adapted from http://jsfiddle.net/tovic/Xcb8d/light/ dragInit = function() { if (!container.classList.contains("collapsed")) { drag.el = container; drag.elm[0] = drag.pos[0] - drag.el.offsetLeft; drag.elm[1] = drag.pos[1] - drag.el.offsetTop; selectionToggle(true); } else { drag.el = null; } drag.time = new Date().getTime() + 500; }, dragMove = function(event) { drag.pos[0] = document.all ? window.event.clientX : event.pageX; drag.pos[1] = document.all ? window.event.clientY : event.pageY; if (drag.el !== null) { drag.el.style.left = (drag.pos[0] - drag.elm[0]) + "px"; drag.el.style.top = (drag.pos[1] - drag.elm[1]) + "px"; drag.el.style.right = "auto"; } }, dragStop = function() { if (drag.el !== null) { dragSave(); selectionToggle(); } drag.el = null; }, dragSave = function(clear) { var val = clear ? null : [container.style.left, container.style.top]; GM_setValue("github-toc-location", val); }, // stop text selection while dragging selectionToggle = function(disable) { var sel, body = document.querySelector("body"); if (disable) { // save current "unselectable" value drag.unsel = body.getAttribute("unselectable"); body.setAttribute("unselectable", "on"); body.classList.add("github-toc-no-selection"); body.addEventListener("onselectstart", selectionStop); } else { if (drag.unsel) { body.setAttribute("unselectable", drag.unsel); } body.classList.remove("github-toc-no-selection"); body.removeEventListener("onselectstart", selectionStop); } // remove text selection - http://stackoverflow.com/a/3171348/145346 sel = window.getSelection ? window.getSelection() : document.selection; if ( sel ) { if ( sel.removeAllRanges ) { sel.removeAllRanges(); } else if ( sel.empty ) { sel.empty(); } } }, selectionStop = function() { return false; }, tocShow = function() { container.classList.remove("collapsed"); GM_setValue("github-toc-hidden", false); }, tocHide = function() { container.classList.add("collapsed"); GM_setValue("github-toc-hidden", true); }, tocToggle = function() { // 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 tocView = function(mode) { document.querySelector(".github-toc").style.display = mode || "none"; }, tocAdd = function() { if (document.querySelectorAll("#wiki-content, #readme")) { var indx, header, anchor, txt, content = ""; tocView("block"); listCollapsible(); busy = false; } else { tocView(); } } else { tocView(); } }, addClass = function(els, name) { var indx, len = els.length; for (indx = 0; indx < len; indx++) { els[indx].classList.add(name); } }, removeClass = function(els, name) { var indx, len = els.length; for (indx = 0; indx < len; indx++) { els[indx].classList.remove(name); } }, listCollapsible = function() { var indx, el, next, count, num, group, els = container.querySelectorAll("li"), len = els.length; for (indx = 0; indx < len; indx++) { count = 0; group = []; el = els[indx]; next = el && el.nextSibling; if (next) { num = el.className.match(/\d/)[0]; while (next && !next.classList.contains("github-toc-h" + num)) { count += next.className.match(/\d/)[0] > num ? 1 : 0; group[group.length] = next; next = next.nextSibling; } if (count > 0) { el.className += " collapsible collapsible-" + indx; addClass(group, "github-toc-childof-" + indx); } } } group = []; container.addEventListener("click", function(event) { if (event.target.classList.contains("github-toc-icon")) { // click on icon, then target LI parent var item = event.target.parentNode, num = item.className.match(/collapsible-(\d+)/), els = num ? container.querySelectorAll(".github-toc-childof-" + num[1]) : null; if (els) { if (item.classList.contains("collapsed")) { item.classList.remove("collapsed"); removeClass(els, "github-toc-hidden"); } else { item.classList.add("collapsed"); addClass(els, "github-toc-hidden"); } } } }); }, // keyboard shortcuts // not sure what GitHub uses, so rolling our own keyboardCheck = function(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 var tocToggle = keyboard.toggle.split("+"), tocReset = keyboard.restore.split("+"), key = String.fromCharCode(event.which).toLowerCase(), 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 (/(input|textarea)/i.test(document.activeElement.nodeName)) { return; } // toggle TOC if (keyboard.lastKey === tocToggle[0] && key === tocToggle[1]) { if (panelHidden) { tocShow(); } else { tocHide(); } } // reset TOC window position if (keyboard.lastKey === tocReset[0] && key === tocReset[1]) { container.setAttribute("style", ""); dragSave(true); } keyboard.lastKey = key; keyboard.timer = setTimeout(function() { keyboard.lastKey = null; }, keyboard.delay); }, // DOM targets - to detect GitHub dynamic ajax page loading targets = document.querySelectorAll([ "#js-repo-pjax-container", // targeted by ZenHub "#js-repo-pjax-container > .container", "#js-pjax-container", ".js-preview-body" ].join(",")), // insert TOC after header tmp = GM_getValue("github-toc-location", null); // restore last position if (tmp) { container.style.left = tmp[0]; container.style.top = tmp[1]; container.style.right = "auto"; } // TOC saved state tmp = GM_getValue("github-toc-hidden", false); container.className = "github-toc boxed-group wiki-pages-box readability-sidebar" + (tmp ? " collapsed" : ""); container.setAttribute("role", "navigation"); container.setAttribute("unselectable", "on"); container.innerHTML = [ "

", " ", "" + title + "", "

", "
" ].join(""); // add container tmp = document.querySelector(".header"); tmp.parentNode.insertBefore(container, tmp); // make draggable container.querySelector("h3").addEventListener("mousedown", dragInit); document.addEventListener("mousemove", dragMove); document.addEventListener("mouseup", dragStop); // toggle TOC container.querySelector(".github-toc-icon").addEventListener("mouseup", tocToggle); // prevent container content selection container.addEventListener("onselectstart", function() { return false; }); // keyboard shortcuts // document.addEventListener("keypress", keyboardCheck); document.addEventListener("keydown", keyboardCheck); // update TOC when content changes Array.prototype.forEach.call(targets, function(target) { new MutationObserver(function(mutations) { mutations.forEach(function(mutation) { // preform checks before adding code wrap to minimize function calls if (!busy && mutation.target === target) { tocAdd(); } }); }).observe(target, { childList: true, subtree: true }); }); // Add GM options GM_registerMenuCommand("Set Table of Contents Title", function() { title = prompt("Table of Content Title:", title); GM_setValue("toc-title", title); container.querySelector("h3 span").textContent = title; }); tocAdd(); })();