Table of Contents Everywhere

On pages which do not have a Table of Contents, but should do, create one! (I actually use this as a bookmarklet, so I can load it onto the current page only when I want it.)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
// ==UserScript==
// @name           Table of Contents Everywhere
// @description    On pages which do not have a Table of Contents, but should do, create one!  (I actually use this as a bookmarklet, so I can load it onto the current page only when I want it.)
// @downstreamURL  http://userscripts.org/scripts/source/123255.user.js
// @license        ISC
// @version        1.0.5
// @include        http://*/*
// @include        https://*/*
// @include        file://*
// @grant          none
// @namespace https://greasyfork.org/users/8615
// ==/UserScript==

var minimumItems = 4;    // Don't display a TOC for fewer than this number of entries.
var maximumItems = 800;  // Don't display a TOC for more than this number of entries.
var delayBeforeRunning = 1600;
var showAnchors = true;
var pushAnchorsToBottom = true;   // They can look messy interspersed amongst TOC tree
var startRolledUp = false;
var runInIframes = false;

// 2015-05-12  Improved shadow styling
// 2015-01-02  Improved styling
// 2012-02-19  Removed verbose log.  Added showAnchors.  Added https since everyone is forcing that now (e.g. github).
// 2012-02-18  Fixed sorting of TOC elements.  Added anchor unicode.
// 2012-01-30  Implemented GM_log and GM_addStyle so this script can be included on any web page.

// TODO: derbyjs.com is an example of a site with a <div id=toc> that has no
// hide or close buttons.  Perhaps we should add close and rollup buttons if we
// cannot find any recognisable buttons.  (Medawiki tocs for example, do have a
// show/hide button, so we don't want to add to them!)

// TODO: whatwg.org presents its own TOC but with no title.  Our buttons appear in the wrong place!

// BUG: Displays links for elements which may be invisible due to CSS.  (e.g. see github markdown pages)

// TODO CONSIDER: TOC hijacking _whitelist_ to avoid creeping fixes for per-site issues.  Different problems are appearing on a small proportion of websites when we try to consume/hijack their existing TOC.  It would be better to create our own *separate* TOC as standard, and only hijack *known* friendly TOCs such as WikiMedia's / Wikia's.
// (We might offer a tiny button "Try to Use Page TOC" allowing us to test hijack before adding the site to the whitelist.)

// Do not run in iframes
if (self !== window.top && !runInIframes) {
  return;
}

setTimeout(function(){



// Implementing these two means we can run as a stand-alone script on any page.
if (typeof GM_log == "undefined") {
	GM_log = function() {
		// Firefox's console.log does not have apply or call functions!
		var txt = Array.prototype.join.call(arguments," ");
		console.log(txt);
	};
}
if (typeof GM_addStyle == "undefined") {
	this.GM_addStyle = function(css) {
		var s = document.createElement("style");
		s.type = 'text/css';
		s.innerHTML = css;
		document.getElementsByTagName("head")[0].appendChild(s);
	};
}

// Implementing these allows us to remember toggled state.  (Chrome's set/getValue don't work.)
if (typeof GM_setValue == 'undefined' || window.navigator.vendor.match(/Google/)) {
	GM_log("[TOCE] Adding fallback implementation of GM_set/getValue");

	if (typeof localStorage == 'undefined') {

		GM_getValue = function(name, defaultValue) {
			return defaultValue;
		};

	} else {

		GM_setValue = function(name, value) {
			value = (typeof value)[0] + value;
			localStorage.setItem(name, value);
		};

		GM_getValue = function(name, defaultValue) {
			var value = localStorage.getItem(name);
			if (!value)
				return defaultValue;
			var type = value[0];
			value = value.substring(1);
			switch (type) {
				case 'b':
					return value == 'true';
				case 'n':
					return Number(value);
				default:
					return value;
			}
		};

	}

}

function loadScript(url,thenCallFn) {
	GM_log("[TOCE] Loading fallback: "+url);
	var scr = document.createElement("script");
	scr.src = url;
	scr.type = "text/javascript";   // Konqueror 3.5 needs this!
	if (thenCallFn) {
		var called = false;
		function onceOnlyCallback(evt) {
			if (!called) {
				called = true;
				thenCallFn(evt);
			}
		}
		function errorCallback(evt) {
			GM_log("[TOCE] Failed to load: "+url,evt);
			onceOnlyCallback(evt);
		}
		scr.addEventListener('load',onceOnlyCallback,false);
		scr.addEventListener('error',errorCallback,false);
		// Fallback in case above events unsupported by browser (e.g. Konq 3.5)
		setTimeout(onceOnlyCallback,5000);
	}
	document.body.appendChild(scr);
}

// Modified for this script's needs.
// Returns e.g. "/*[2]/*[4]/*[9]"
function getXPath(node) {
	var parent = node.parentNode;
	if (!parent) {
		return '';
	}
	var siblings = parent.childNodes;
	var totalCount = 0;
	var thisCount = -1;
	for (var i=0;i<siblings.length;i++) {
		var sibling = siblings[i];
		if (true /*sibling.nodeType == node.nodeType*/) {
			totalCount++;
		}
		if (sibling == node) {
			thisCount = totalCount;
			break;
		}
	}
	// return getXPath(parent) + '/*' /*node.nodeName.toLowerCase()*/ + (totalCount>1 ? '[' + thisCount + ']' : '' );
	// Remain consistent:
	return getXPath(parent) + '/*' + '[' + thisCount + ']';
}

// Konqueror 3.5 lacks some things!
if (!Array.prototype.map) {
	Array.prototype.map = function(fn) {
		var l = [];
		for (var i=0;i<this.length;i++) {
			l.push(fn(this[i]));
		}
		return l;
	};
}
if (!String.prototype.trim) {
	String.prototype.trim = function() {
		return this.replace(/^[ \t]+/,'').replace(/[ \t]+$/,'');
	};
}



// The following block is mirrored in wikiindent.user.js

// See also: resetProps
function clearStyle(elem) {
	// We set some crucial defaults, so we don't inherit CSS from the page:
	elem.style.display = 'inline';
	elem.style.position = 'static';
	elem.style.top = 'auto';
	elem.style.right = 'auto';
	elem.style.bottom = 'auto';
	elem.style.left = 'auto';
	elem.style.color = 'black';
	elem.style.backgroundColor = '#f4f4f4';
	elem.style.border = '0px solid magenta';
	elem.style.padding = '0px';
	elem.style.margin = '1px';
	return elem;
}

function newNode(tag,data) {
	var elem = document.createElement(tag);
	if (data) {
		for (var prop in data) {
			elem[prop] = data[prop];
		}
	}
	return elem;
}

function newSpan(text) {
	return clearStyle(newNode("span",{textContent:text}));
}

function addCloseButtonTo(where, toc) {
	var closeButton = newSpan("[X]");
	// closeButton.style.float = 'right';
	// closeButton.style.cssFloat = 'right'; // Firefox
	// closeButton.style.styleFloat = 'right'; // IE7
	closeButton.style.cursor = 'pointer';
	closeButton.style.paddingLeft = '5px';
	closeButton.onclick = function() { toc.parentNode.removeChild(toc); };
	closeButton.id = "closeTOC";
	where.appendChild(closeButton);
}

function addHideButtonTo(toc, tocInner) {
	var rollupButton = newSpan("[-]");
	// rollupButton.style.float = 'right';
	// rollupButton.style.cssFloat = 'right'; // Firefox
	// rollupButton.style.styleFloat = 'right'; // IE7
	rollupButton.style.cursor = 'pointer';
	rollupButton.style.paddingLeft = '10px';
	function toggleRollUp() {
		if (tocInner.style.display == 'none') {
			tocInner.style.display = '';
			rollupButton.textContent = "[-]";
		} else {
			tocInner.style.display = 'none';
			rollupButton.textContent = "[+]";
		}
		setTimeout(function(){
			GM_setValue("TOCE_rolledUp", tocInner.style.display=='none');
		},5);
	}
	rollupButton.onclick = toggleRollUp;
	rollupButton.id = "togglelink";
	toc.appendChild(rollupButton);
	if (startRolledUp || GM_getValue("TOCE_rolledUp",false)) {
		toggleRollUp();
	}
}

function addButtonsConditionally(toc) {

	function verbosely(fn) {
		return function() {
			// GM_log("[WI] Calling: "+fn+" with ",arguments);
			return fn.apply(this,arguments);
		};
	};

	// Provide a hide/show toggle button if the TOC does not already have one.

	// Wikimedia's toc element is actually a table.  We must put the
	// buttons in the title div, if we can find it!

	var tocTitle = document.getElementById("toctitle"); // Wikipedia
	tocTitle = tocTitle || toc.getElementsByTagName("h2")[0]; // Mozdev
	// tocTitle = tocTitle || toc.getElementsByTagName("div")[0]; // Fingers crossed for general
	tocTitle = tocTitle || toc.firstChild; // Fingers crossed for general

	// Sometimes Wikimedia does not add a hide/show button (if the TOC is small).
	// We cannot test this immediately, because it gets loaded in later!
	function addButtonsNow() {

		var hideShowButton = document.getElementById("togglelink");
		if (!hideShowButton) {
			var tocInner = toc.getElementsByTagName("ol")[0]; // Mozdev (can't get them all!)
			tocInner = tocInner || toc.getElementsByTagName("ul")[0]; // Wikipedia
			tocInner = tocInner || toc.getElementsByTagName("div")[0]; // Our own
			if (tocInner) {
				verbosely(addHideButtonTo)(tocTitle || toc, tocInner);
			}
		}

		// We do this later, to ensure it appears on the right of
		// any existing [hide/show] button.
		if (document.getElementById("closeTOC") == null) {
			verbosely(addCloseButtonTo)(tocTitle || toc, toc);
		}

	}

	// Sometimes Wikimedia does not add a hide/show button (if the TOC is small).
	// We cannot test this immediately, because it gets loaded in later!
	if (document.location.href.indexOf("wiki") >= 0) {
		setTimeout(addButtonsNow,2000);
	} else {
		addButtonsNow();
	}

}

// End mirror.



// == Main == //

function buildTableOfContents() {

	// Can we make a TOC?
	var headers = "//h1 | //h2 | //h3 | //h4 | //h5 | //h6 | //h7 | //h8";
	var anchors = "//a[@name]";
	// For coffeescript.org:
	var elementsMarkedAsHeader = "//*[@class='header']";
	// However on many sites that might be the thing opposite the footer, and probably not of note.

	var xpathQuery = headers+(showAnchors?"|"+anchors:"")+"|"+elementsMarkedAsHeader;
	var nodeSnapshot = document.evaluate(xpathQuery,document,null,6,null);
	//// Chrome needs lower-case 'h', Firefox needs upper-case 'H'!
	// var nodeSnapshot = document.evaluate("//*[starts-with(name(.),'h') and substring(name(.),2) = string(number(substring(name(.),2)))]",document,null,6,null);
	// var nodeSnapshot = document.evaluate("//*[starts-with(name(.),'H') and substring(name(.),2) = string(number(substring(name(.),2)))]",document,null,6,null);

	if (nodeSnapshot.snapshotLength > maximumItems) {
		GM_log("[TOCE] Too many nodes for table (sanity): "+nodeSnapshot.snapshotLength);
	} else if (nodeSnapshot.snapshotLength >= minimumItems) {

		GM_log("[TOCE] Making TOC with "+nodeSnapshot.snapshotLength+" nodes.");

		var toc = newNode("div");
		toc.id = 'toc';

		// var heading = newSpan("Table of Contents");
		var heading = clearStyle(newNode("h2",{textContent:"Table of Contents"}));
		heading.id = 'toctitle';   // Like Wikipedia
		heading.style.fontWeight = "bold";
		heading.style.fontSize = "100%";
		toc.appendChild(heading);

		var table = newNode("div");
		// addHideButtonTo(toc,table);
		table.id = 'toctable';   // Our own
		toc.appendChild(table);

		// We need to do this *after* adding the table.
		addButtonsConditionally(toc);

		// The xpath query did not return the elements in page-order.
		// We sort them back into the order they appear in the document
		// Yep it's goofy code, but it works.
		var nodeArray = [];
		for (var i=0;i<nodeSnapshot.snapshotLength;i++) {
			var node = nodeSnapshot.snapshotItem(i);
			nodeArray.push(node);
			// We need to sort numerically, since with strings "24" < "4"
			node.magicPath = getXPath(node).substring(3).slice(0,-1).split("]/*[").map(Number);
			if (pushAnchorsToBottom && node.tagName==="A") {
				node.magicPath.unshift(+Infinity);
			}
		}
		nodeArray.sort(function(a,b){
			// GM_log("[TOCE] Comparing "+a.magicPath+" against "+b.magicPath);
			for (var i=0;i<a.magicPath.length;i++) {
				if (i >= b.magicPath.length) {
					return +1; // b wins (comes earlier)
				}
				if (a.magicPath[i] > b.magicPath[i]) {
					return +1; // b wins
				}
				if (a.magicPath[i] < b.magicPath[i]) {
					return -1; // a wins
				}
			}
			return -1; // assume b is longer, or they are equal
		});

		for (var i=0;i<nodeArray.length;i++) {
			var node = nodeArray[i];

			var level = (node.tagName.substring(1) | 0) - 1;
			if (level < 0) {
				level = 0;
			}

			var linkText = node.textContent && node.textContent.trim() || node.name;
			if (!linkText) {
				continue;   // skip things we cannot name
			}

			var link = clearStyle(newNode("A"));
			if (linkText.length > 40) {
				link.title = linkText;   // Show full title on hover
				linkText = linkText.substring(0,32)+"...";
			}
			link.textContent = linkText;
			/* Dirty hack for Wikimedia: */
			if (link.textContent.substring(0,7) == "[edit] ") {
				link.textContent = link.textContent.substring(7);
			}
			if (node.tagName == "A") {
				link.href = '#'+node.name;
			} else {
				(function(node){
					link.onclick = function(evt){
						node.scrollIntoView();

						// Optional: CSS animation
						// NOT WORKING!
						/*
						node.id = "toc_current_hilight";
						["","-moz-","-webkit-"].forEach(function(insMode){
							GM_addStyle("#toc_current_hilight { "+insMode+"animation: 'fadeHighlight 4s ease-in 1s alternate infinite'; }@"+insMode+"keyframes fadeHighlight { 0%: { background-color: yellow; } 100% { background-color: rgba(255,255,0,0); } }");
						});
						*/

						evt.preventDefault();
						return false;
					};
				})(node);
				link.href = '#';
			}
			table.appendChild(link);

			// For better layout, we will now replace that link with a neater li.
			liType = "li";
			if (node.tagName == "A") {
				liType = "div";
			}
			var li = newNode(liType);
			// clearStyle(li); // display:inline; is bad on LIs!
			// li.style.display = 'list-item';   // not working on Github
			link.parentNode.replaceChild(li,link);
			if (node.tagName == "A") {
				li.appendChild(document.createTextNode("\u2693 "));
			}
			li.appendChild(link);
			li.style.paddingLeft = (1.5*level)+"em";
			li.style.fontSize = (100-6*(level+1))+"%";
			li.style.size = li.style.fontSize;

			// Debugging:
			/*
			li.title = node.tagName;
			if (node.name)
				li.title += " (#"+node.name+")";
			li.title = getXPath(node);
			*/

		}

		document.body.appendChild(toc);

		// TODO scrollIntoView if newly matching 1.hash exists

		postTOC(toc);

	} else {
		GM_log("[TOCE] Not enough items found to create toc.");
	}

	return toc;

}

function postTOC(toc) {
	if (toc) {

		// We make the TOC float regardless whether we created it or it already existed.
		// Interestingly, the overflow settings seems to apply to all sub-elements.
		// E.g.: http://mewiki.project357.com/wiki/X264_Settings#Input.2FOutput
		// FIXED: Some of the sub-trees are so long that they also get scrollbars, which is a bit messy!
		// FIXED : max-width does not do what I want!  To see, find a TOC with really wide section titles (long lines).

		// Also in Related_Links_Pager.user.js
		// See also: clearStyle
		var resetProps = " width: auto; height: auto; max-width: none; max-height: none; ";

		if (toc.id === "") {
			toc.id = "toc";
		}
		var tocID = toc.id;
		GM_addStyle("#"+tocID+" { position: fixed; top: 10%; right: 4%; background-color: #f4f4f4; color: black; font-weight: normal; padding: 5px; border: 1px solid grey; z-index: 9999999; "+resetProps+" }" // max-height: 80%; max-width: 32%; overflow: auto; 
			+ "#"+tocID+"               { opacity: 0.4; }"
			+ "#"+tocID+":hover         { box-shadow: 0px 2px 10px 1px rgba(0,0,0,0.4); }"
			+ "#"+tocID+":hover         { -webkit-box-shadow: 0px 1px 4px 0px rgba(0,0,0,0.4); }"
			+ "#"+tocID+":hover         { opacity: 1.0; }"
			+ "#"+tocID+"       > * > * { opacity: 0.0; }"
			+ "#"+tocID+":hover > * > * { opacity: 1.0; }"
			+ "#"+tocID+" , #"+tocID+" > * > * { transition: opacity; transition-duration: 400ms; }"
			+ "#"+tocID+" , #"+tocID+" > * > * { -webkit-transition: opacity; -webkit-transition-duration: 400ms; }"
		);
		GM_addStyle("#"+tocID+" > * { "+resetProps+" }");

		var maxWidth = window.innerWidth * 0.40 | 0;
		var maxHeight = window.innerHeight * 0.80 | 0;

		var table = document.getElementById("toctable");
		table = table || toc.getElementsByTagName("ul")[0];   // Wikipedia
		table = table || toc;   // Give up, set for whole element
		table.style.overflow = 'auto';
		table.style.maxWidth = maxWidth+"px";
		table.style.maxHeight = maxHeight+"px";

	}
}

function searchForTOC() {

	try {

		var tocFound = document.getElementById("toc");
		// Konqueror 3.5 does NOT have document.getElementsByClassName(), so we check for it.
		tocFound = tocFound || (document.getElementsByClassName && document.getElementsByClassName("toc")[0]);
		tocFound = tocFound || document.getElementById("article-nav");   // developer.mozilla.org
		tocFound = tocFound || document.getElementById("page-toc");      // developer.mozilla.org
		tocFound = tocFound || (document.getElementsByClassName && document.getElementsByClassName("twikiToc")[0]);      // TWiki
		tocFound = tocFound || document.getElementById("TOC");           // meteorpedia.com
		tocFound = tocFound || document.location.host==="developer.android.com" && document.getElementById("qv");
		if (document.location.host.indexOf("dartlang.org")>=0) {
			tocFound = null;   // The toc they gives us contains top-level only.  It's preferable to generate our own full tree.
		}
		// whatwg.org:
		/* if (document.getElementsByTagName("nav").length == 1) {
			GM_log("[TOCE] Using nav element.");
			tocFound = document.getElementsByTagName("nav")[0];
		} */

		var toc = tocFound;

		// With the obvious exception of Wikimedia sites, most found tocs do not contain a hide/close button.
		// TODO: If we are going to make the toc float, we should give it rollup/close buttons, unless it already has them.
		// The difficulty here is: where to add the buttons in the TOC, and which part of the TOC to hide, without hiding the buttons!
		// Presumably we need to identify the title element (first with textContent) and collect everything after that into a hideable block (or hide/unhide each individually when needed).

		if (toc) {

			postTOC(toc);

			addButtonsConditionally(toc);

		} else {

			toc = buildTableOfContents();

		}

	} catch (e) {
		GM_log("[TOCE] Error! "+e);
	}

}

if (document.evaluate /*this.XPathResult*/) {
	searchForTOC();
} else {
	loadScript("http://hwi.ath.cx/javascript/xpath.js", searchForTOC);
}



},delayBeforeRunning);
// We want it to run fairly soon but it can be quite heavy on large pages - big XPath search.