joeytwiddle / WikiIndent

// ==UserScript==
// @name           WikiIndent
// @namespace      joeytwiddle
// @description    Four visual improvements for Wikipedia (and other wikis):  Indents sub-sections to make the layout clearer.  Hides the sidebar (toggle by clicking the header).  Floats the Table of Contents for access when scrolled.  Converts heading underlines to overlines.
// @downstreamURL  http://userscripts.org/scripts/source/60832.user.js
// @version        1.3.4
// @include        *wiki*
// @include        http://www.buzztard.com/*
// @include        http://encyclopediadramatica.com/*
// @include        http://www.wormus.com/leakytap/*
// @include        http://theinfosphere.org/*
// @include        http://rosettacode.org/mw/*
// @grant          GM_setValue
// @grant          GM_addStyle
// @grant          GM_getValue
// @grant          GM_log
// ==/UserScript==

// Without this function wrapper, Mozilla Firefox rejects the whole script, because it sees the top-level 'return;' as invalid syntax!

(function(){

// Feature #1 : Make the sidebar collapsible so the page content can fill the whole width.

var toggleSidebar = true;

// Feature #2 : Float the TOC on the top-right of the screen, so it can still be used after scrolling down the page.

var makeTableOfContentsFloat = true;

// Feature #3 : Indent the blocks so their tree-like structure is visible.

var indentSubBlocks = true;

// Feature #4 : Change underlined headings to overlined headings.  (So the lines separate the heading from the previous section, rather than separating the heading from its content.)

var fixUnderlinesToOverlines = true;



// var minimisedSidebarSize = 6;   // Small
var minimisedSidebarSize = 16;

// When opening the sidebar again, the transition displays the sidebar contents
// before there is space for it, causing brief ugly overlap!  So we delay
// unhiding to look prettier.
// CONSIDER: Perhaps this could look even smoother if the text appeared/disappeared using opacity.
var delayHide = 0;
var delayUnhide = ( document.getElementById("mw-panel") ? 250 : 0 );

var debug = false;



/* CONSIDER: As we scroll the page, light up the "current" section in the TOC.
 *
 * FIXED: One occasional problem with the TOC is when it is taller than the
 *      window!  (I usually work around this by zooming out (reducing font
 *      size), but perhaps we can use CSS overflow to solve it properly.)
 *
 * TODO: Indentation was not working well in edit preview on Hwiki(MW).
*/

/* Changelog
 *  5/ 2/2012 - Better (though more fragile) click-to-toggle areas.
 *  3/ 1/2012 - Fixed Chrome compatibility so it works!  Doh.
 * 23/ 3/2011 - Added Chrome compatibility.
*/

// Recent versions do not play nice together, so just in case we run WI twice:
if (unsafeWindow.WikiIndent_loaded) {
	return;
} else {
	unsafeWindow.WikiIndent_loaded = true;
}

function log(x) {
	x = "[WI] "+x;
	if (this.GM_log) {
		this.GM_log(x);
	} else if (this.console && console.log) {
		console.log(x);
	} else {
		window.status = ""+x;
		// alert(x);
	}
}

// For bookmarklets:
if (typeof GM_addStyle == "undefined") {
	GM_addStyle = function(css) {
		var head, style;
		head = document.getElementsByTagName("head")[0];
		if (!head) { return; }
		style = document.createElement("style");
		style.type = "text/css";
		style.innerHTML = css;
		head.appendChild(style);
	};
}

if (typeof GM_setValue == 'undefined' || window.navigator.vendor.match(/Google/)) {
	GM_log("WikiIndent: 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;
			}
		};

	}

}



// The following block is mirrored in table_of_contents_everyw.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 = 'white';
	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 addCloseButtonTo(where, toc) {
	var closeSpan = newNode("span");
	var closeLink = newNode("a",{textContent:"close"});
	closeLink.onclick = function() { toc.parentNode.removeChild(toc); };
	closeLink.id = "closeTOC";
	closeLink.style.cursor = 'pointer';
	closeSpan.appendChild(document.createTextNode("["));
	closeSpan.appendChild(closeLink);
	closeSpan.appendChild(document.createTextNode("]"));
	//closeSpan.style.paddingLeft = '5px';
	where.appendChild(closeSpan);
}

function addHideButtonTo(where, tocInner) {
	var rollupSpan = newNode("span");
	var rollupLink = newNode("a",{textContent:"hide"});
	rollupLink.onclick = toggleRollUp;
	rollupLink.id = "togglelink";
	rollupLink.className = "togglelink";
	rollupLink.style.cursor = 'pointer';
	rollupSpan.style.paddingLeft = '5px';
	rollupSpan.style.paddingRight = '5px';
	function toggleRollUp() {
		if (tocInner.style.display == 'none') {
			tocInner.style.display = '';
			rollupLink.textContent = "hide";
		} else {
			tocInner.style.display = 'none';
			rollupLink.textContent = "show";
		}
		setTimeout(function(){
			GM_setValue("WI_toc_rolledUp", tocInner.style.display=='none');
		},5);
	}
	rollupSpan.appendChild(document.createTextNode("["));
	rollupSpan.appendChild(rollupLink);
	rollupSpan.appendChild(document.createTextNode("]"));
	where.appendChild(rollupSpan);
	if (GM_getValue("WI_toc_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") || toc.getElementsByClassName("togglelink")[0];
		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 doIt() {



	//// Feature #1 : Hide the sidebar.  Fullsize the content.

	// Toggle the sidebar by clicking the "page background" (empty space outside
	// the main content).  Sometimes clicking the content background is enough.

	if (toggleSidebar) {

		var content = document.getElementById("content")
			|| document.getElementById("column-content");
		var sideBar = document.getElementById("column-one")
			|| document.getElementById("panel")
			|| /* WikiMedia: */ document.getElementById("mw-panel")
			|| /* forgot:    */ document.getElementById("jq-interiorNavigation")
			|| /* pmwiki:    */ document.getElementById('wikileft');
		var toToggle = [ document.getElementById("page-base"), document.getElementById("siteNotice"), document.getElementById("head") ];
		var cac = document.getElementById("p-cactions");
		var cacOldHome = ( cac ? cac.parentNode : null );

		function toggleWikipediaSidebar(evt) {

			// We don't want to act on all clicked body elements (notably not the WP
			// image).  I detected two types of tag we wanted to click.
			/*if (!evt || evt.target.tagName == "UL" || evt.target.tagName == "DIV") {*/

			// That was still activating on divs in the content!  (Gaps between paragraphs.)
			// This only acts on the header area.
			var thisElementTogglesSidebar;
			var inStartup = (evt == null);
			if (inStartup) {
				thisElementTogglesSidebar = true;
			} else {
				var elem = evt.target;
				var clickedHeader = (elem.id == 'mw-head');
				// For wikia.com:
				clickedHeader |= (elem.id=="WikiHeader");
				// For Wikimedia:
				var clickedPanelBackground = elem.id == 'mw-panel' || elem.className.indexOf('portal')>=0;
				clickedPanelBackground |= elem.id == 'column-content';  // for beebwiki (old mediawiki?)
				// Hopefully for sites in general.  Allow one level below body.  Needed for Wikia's UL.
				var clickedAreaBelowSidebar = (elem.tagName == 'HTML' || elem.tagName == 'BODY');
				var clickedBackground = (elem.parentNode && elem.parentNode.tagName == "BODY");
				thisElementTogglesSidebar = clickedHeader || clickedPanelBackground || clickedAreaBelowSidebar || clickedBackground;
			}
			if (thisElementTogglesSidebar) {

				if (evt)
					evt.preventDefault();
				if (debug) { GM_log("evt=",evt); }
				// if (evt) GM_log("evt.target.tagName="+evt.target.tagName);
				/* We put the GM_setValue calls on timers, so they won't slow down the rendering. */
				// Make the change animate smoothly:
				content.style.transition = 'all 150ms ease-in-out';
				if (sideBar) {
					if (sideBar.style.display == '') {
						// Wikipedia's column-one contains a lot of things we want to hide
						sideBar.style.display = 'none';
						if (content) {
							content.oldMarginLeft = content.style.marginLeft;
							content.style.marginLeft = minimisedSidebarSize+'px';
						}
						for (var i in toToggle) {
							if (toToggle[i]) { toToggle[i].style.display = 'none'; }
						}
						// but one of them we want to preserve
						// (the row of tools across the top):
						if (cac)
							sideBar.parentNode.insertBefore(cac,sideBar.nextSibling);
						setTimeout(function(){
							GM_setValue("sidebarVisible",false);
						},200);
					} else {
						function unhide() {
							sideBar.style.display = '';
						}
						setTimeout(unhide,delayUnhide);
						if (content) {
							content.style.marginLeft = content.oldMarginLeft;
						}
						for (var i in toToggle) {
							if (toToggle[i]) { toToggle[i].style.display = ''; }
						}
						if (cac && cacOldHome)
							cacOldHome.appendChild(cac); // almost back where it was :P
						setTimeout(function(){
							GM_setValue("sidebarVisible",true);
						},200);
					}
				}

			}
		}

		// log("sideBar="+sideBar+" and content="+content);
		if (sideBar) {
			// We need to watch window for clicks below sidebar (Chrome).
			document.documentElement.addEventListener('click',toggleWikipediaSidebar,false);
		} else {
			log("Did not have sideBar "+sideBar+" or content "+content); // @todo Better to warn or error?
		}

		if (!GM_getValue("sidebarVisible",true)) {
			toggleWikipediaSidebar();
		}

		// TODO: Make a toggle button for it!

		// Fix for docs.jquery.com:
		/*
		var j = document.getElementById("jq-primaryContent");
		if (j) {
			j.style.setAttribute('display', 'block');
			j.style.setAttribute('float', 'none');
			j.style.setAttribute('width', '100%');
		}
		*/
		GM_addStyle("#jq-primaryContent { display: block; float: none; width: 100%; }");

	}



	//// Feature #2: Make Table of Contents float

	if (makeTableOfContentsFloat) {

		/* @consider If the TOC has a "Hide/Show" link ("button") then we could
		 * fire that instead of changing opacity.
		 */

		// document.getElementById('column-one').appendChild(document.getElementById('toc'));

		// createFader basically worked but was a little bit buggy.  (Unless the bugs were caused by conflict with other TOC script.)
		// Anyway createFader() has now been deprecated in favour of CSS :hover.

		function createFader(toc) {

			var timer = null;

			// BUG: this didn't stop the two fades from conflicting when the user wiggles the mouse to start both!
			function resetTimeout(fn,ms) {
				if (timer) {
					clearTimeout(timer);
				}
				setTimeout(fn,ms);
			}

			function fadeElement(elem,start,stop,speed,current) {
				if (current == null)
					current = start;
				if (speed == null)
					speed = (stop - start) / 8;
				if (Math.abs(current+speed-stop) > Math.abs(current-stop))
					current = stop;
				else
					current = current + speed;
				elem.style.opacity = current;
				if (current != stop)
					resetTimeout(function(){fadeElement(elem,start,stop,speed,current);},50);
			}

			toc.style.opacity = 0.3;
			var listenElement = toc;
			// var listenElement = toc.getElementsByTagName('TD')[0];
			var focused = false;
			var visible = false;
			listenElement.addEventListener('mouseover',function(){
				if (!visible)
					setTimeout(function(){ if (focused) { visible=true; fadeElement(toc,0.4,1.0,0.2); } },10);
				focused = true;
			},false);
			listenElement.addEventListener('mouseout',function(){
				if (visible)
					setTimeout(function(){ if (!focused) { visible=false; fadeElement(toc,1.0,0.2,-0.1); } },10);
				focused = false;
			},false);

		}


		function tryTOC() {

			// Find the table of contents element:
			var toc = document.getElementById("toc")   /* MediaWiki */
					 || document.getElementsByClassName("table-of-contents")[0]   /* BashFAQ */
					 || document.getElementsByClassName("toc")[0]   /* LeakyTap */
					 || document.getElementsByClassName("wt-toc")[0];   /* Wikitravel */

			if (toc) {

				addButtonsConditionally(toc);

				// toc.style.backgroundColor = '#eeeeee';
				// alert("doing it!");
				toc.style.position = 'fixed';
				toc.style.right = '16px';
				// toc.style.top = '16px';
				// A healthy gap from the top allows the user to access things fixed in the top right of the page, if they can scroll finely enough.
				// toc.style.top = '24px';
				//toc.style.right = '4%';
				//toc.style.top = '10%';
				toc.style.right = '4px';
				toc.style.top = '84px';   // We want to be below the search box!
				// toc.style.left = '';
				// toc.style.bottom = '';
				toc.style.zIndex = '5000';
				// fadeElement(toc,1.0,0.4);
				// This might work for a simple toc div
				toc.style.maxHeight = "80%";
				toc.style.maxWidth = "32%";

				/* 
				 * Sometimes specifying max-height: 80% does not work, the toc won't shrink.
				 * This may be when it's a table and not a div.  Then we must set max-height on the content.  (Maybe we don't actually need to set pixels if we find the right element.)
				 */
				toc.id = "toc";
				var maxHeight = window.innerHeight * 0.8 | 0;
				var maxWidth = window.innerWidth * 0.4 | 0;

				/*
				 * WikiMedia tree looks like this: <table id="toc" class="toc"><tbody><tr><td><div id="toctitle"><h2>Contents</h2>...</div> <ul> <li class="toclevel-1 tocsection-1">
				 Here is a long TOC: http://mewiki.project357.com/wiki/X264_Settings#Input.2FOutput
				 */
				// GM_addStyle("#toc ul { overflow: auto; max-width: "+maxWidth+"px; max-height: "+maxHeight+"px; }");
				var rootUL = toc.getElementsByTagName("UL")[0];
				if (!rootUL)
					rootUL = toc;
				// DONE: If we can cleanly separate them, we might want to put a scrollbar on the content element, leaving the title outside it.
				rootUL.style.overflow = "auto";
				rootUL.style.maxWidth = maxWidth+'px';
				rootUL.style.maxHeight = maxHeight+'px';

				// But if calc and vh are available, then we can make it adaptive
				// Of this 132px, 84px comes from the 'top', and the rest comes from the toc title and padding.
				rootUL.style.maxHeight = "calc(100vh - 128px)";

				// Slide up into the corner as the page scrolls
				window.addEventListener('scroll', checkSize);
				window.addEventListener('resize', checkSize);
				
				function checkSize () {
					var top = Math.min(84, Math.max(4, 84 - document.body.scrollTop));
					document.getElementById('toc').style.top = top + 'px';
					rootUL.style.maxHeight = (window.innerHeight - top - 44) + 'px';
				}

				/*
				createFader(toc);
				*/
				//// Alternative rules from table_of_contents_everywhere script:
				toc.id = "toc";
				// GM_addStyle("#toc { position: fixed; top: 10%; right: 4%; background-color: white; color: black; font-weight: normal; padding: 5px; border: 1px solid grey; z-index: 5555; max-height: 80%; overflow: auto; }");
				GM_addStyle("#toc       { opacity: 0.2; }");
				GM_addStyle("#toc:hover { opacity: 1.0; }");

				var tocID = "toc";
				var resetProps = "";
				// This is a clone of the code in table_of_contents_everyw.user.js
				GM_addStyle(
					  "#"+tocID+" {"
					+ "    position: fixed;"
					+ "    top: 84px;"
					+ "    right: 4px;"
					+ "    background-color: #f4f4f4;"
					+ "    color: black;"
					+ "    font-weight: normal;"
					+ "    padding: 5px;"
					//+ "    border: 1px solid grey;"
					+ "    z-index: 9999999;"
					+ "    "+resetProps
					+ "}"
					+ "#"+tocID+"               { opacity: 0.3; }"
					+ "#"+tocID+"               { border: 1px solid #ccc; }"
					+ "#"+tocID+":hover         { border: 1px solid grey; }"
					+ "#"+tocID+":hover         { box-shadow: 0px 2px 12px 0px rgba(0,0,0,0.1); }"
					+ "#"+tocID+":hover         { -webkit-box-shadow: 0px 2px 12px 0px rgba(0,0,0,0.1); }"
					+ "#"+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; }"
					+ "#"+tocID+"               { padding: 0; }"
					+ "#"+tocID+" > div         { padding: 4px 12px; }"
					+ "#"+tocID+" > ul          { padding: 0px 12px 2px 12px; margin-top: 0; }"
				);

				// For Wikia (tested in Chrome):
				if (getComputedStyle(toc)["background-color"] == "rgba(0, 0, 0, 0)") {
					toc.style.backgroundColor = 'white';
				}

				checkSize();

				return true;

			}

			return false;

		}

		// Ideally we want to act before # anchor position occurs, but we may
		// need to wait for the toc if it is not added to the DOM until later.
		if (!tryTOC()) {
			setTimeout(tryTOC,400);
		}

	}



	// In case you have * in your includes, only continue for pages which have
	// "wiki" before "?" in the URL, or who have both toc and content elements.
	var isWikiPage = document.location.href.split("?")[0].match("wiki")
		|| ( document.getElementById("toc") && document.getElementById("content") );

	if (!isWikiPage)
		return;



	// Delay.  Feature 3 and 4 can run a bit later, without *too* much page
	// change, but with significant processor saving!
	setTimeout(function(){



	//// Feature #3 : Indent the blocks so their tree-like structure is visible

	// Oct 2012: Disabled - was making a right mess of the header/nav on Wikia
	if (document.location.host.match(/wikia.com/)) {
		indentSubBlocks = false;
	}

	if (indentSubBlocks) {

		function indent(tag) {
			// By targetting search we avoid indenting any blocks in left-hand-column (sidebar).
			var whereToSearch = document.getElementById('bodyContent') || document.getElementById('content') || document.getElementById('WikiaMainContent') || document.body;
			var elems = whereToSearch.getElementsByTagName(tag);
			if (elems.length == 1)
				return;
			// for (var i=0;i<elems.length;i++) {
			for (var i=elems.length;i-->0;) {
				var elem = elems[i];
				/* Don't fiddle with main heading, siteSub, or TOC. */
				if (elem.className == 'firstHeading')
					continue;
				if (elem.id == 'siteSub')
					continue;
				if (elem.textContent == 'Contents')
					continue;

				// We have found a "heading" element.  Every sibling after this
				// element should be indented a bit.

				//// Current method of indenting:  Create a UL and put everything
				//// inside that.
				// var newChild = document.createElement('blockquote');
				//// Unfortunately blockquotes tend to indent too much!
				// var newChild = document.createElement('DIV');
				//var newChild = document.createElement('UL'); // UL works better with my Folding script, but we must not do this to the TOC!
				var newChild = document.createElement('div'); // <ul>s look wrong on bitbucket wikis (indent too much).  And since I haven't used my folding script recently, I am switching back to a nice <div>.
				newChild.style.marginLeft = '1.0em';
				var toAdd = elem.nextSibling;
				while (toAdd && toAdd.tagName != tag) {
					// That last condition means a h3 might swallow an h2 if they
					// are on the same level!  But it *should* swallow an h4.
					// TODO: We should break if we encounter any marker with level
					// above or equal to our own, otherwise continue to swallow.
					var next = toAdd.nextSibling;
					newChild.appendChild(toAdd);
					toAdd = next;
				}
				elem.parentNode.insertBefore(newChild,elem.nextSibling);

				// CONSIDER: Alternative: Do not swallow at all, do not create
				// newChild and change the page's tree.  Just modify
				// style.marginLeft, resetting it if an incompatible element style
				// already exists there, updating it if we have already indented
				// this element!

				// GM_log("Placed "+newChild+" after "+elem);
			}
		}

		indent("H1"); indent("H2"); indent("H3"); indent("H4"); indent("H5"); indent("H6");

	}



	//// Feature #4: Change underlined headings to overlined headings.

	if (fixUnderlinesToOverlines) {

		// Hide any existing underlines
		// I made this !important to defeat the more specific `.markdown-body h*` rules on GitHub wikis.
		GM_addStyle("h1, h2, h3, h4, h5, h6 { border-bottom: 0 !important; }");

		// Add our own overlines instead
		GM_addStyle("h1, h2, h3, h4, h5, h6 { border-top: 1px solid #AAAAAA; }");

		// Do not use `text-decoration: underline;`.  It will only appear as wide as the text (not filling the page width) and will make the text look like a hyperlink!

	}



	},1000);




} // end doIt


// setTimeout(doIt,2000);
doIt();

})();