User:Matma Rex/article-map.js

From Meta, a Wikimedia project coordination wiki

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Press Ctrl-F5.
/*!
 * Proof-of-concept of an "article map" sidebar for Wikipedia articles.
 * Displays a scaled-down version of the page on the right side of the window.
 * A button will appear in the top-right corner, clicking it will enable the map.
 *
 * Doesn't work correctly on Chrome (and Chrome-like browsers) because of its
 * numerous bugs, doesn't work on Internet Explorer because of its lack of
 * support for advanced SVG features.
 *
 * Works on Firefox (but gets laggy for *very* long articles), works on Opera 12
 * (but gets laggy for long articles).
 *
 * To enable, add the following snippet to your common.js page (on any wiki):
 *   mw.loader.load('//meta.wikimedia.org/w/index.php?title=User:Matma_Rex/article-map.js&action=raw&ctype=text/javascript');
 * 
 * If you're reckless and don't mind your browser possibly locking up from time
 * to time, and want to always display the map immediately when a page is loaded,
 * also add this snippet:
 *   window.articleMapRenderOnLoad = true;
 *
 * Author: Bartosz Dziewoński ([[User:Matma Rex]])
 * Released under the terms of the MIT license.
 * Little attention was paid to details and code quality. Beware.
 * version 1.1
 */

/*global $, mw */

( function () {
	var sidebarWidth = 150;
	var displaying = false;

	var loadPromise = $.Deferred();
	if(document.readyState === "complete") {
		loadPromise.resolve();
	} else {
		$(window).on('load', function () {
			loadPromise.resolve();
		});
	}

	var contentWidth, scale, contentHeight, sidebarHeight, windowHeight, contentTop, contentBottom,
		maxContentScrollTop, needSidebarScrolling, needSidebarBottomOffset, maxSidebarScrollTop;

	var $sidebar, $indicator, $svg, $closeButton, $setupButton;
	function renderSidebar() {
		if(displaying) return;
		displaying = true;

		// mw.util.addCSS( '#content { transition: margin-right 300ms }' );
		// mw.util.addCSS( '#content-sidebar { transition: width 300ms }' );

		$setupButton.remove();
		$('#content')
			.css('margin-right', sidebarWidth);

		contentWidth = $('#content').outerWidth();
		scale = sidebarWidth / contentWidth;
		contentHeight = $('#content').outerHeight();
		sidebarHeight = contentHeight * scale;
		windowHeight = $(window).height();

		contentTop = $('#content').offset().top;
		contentBottom = contentTop + contentHeight - windowHeight;

		maxContentScrollTop = document.documentElement.scrollHeight - windowHeight;
		needSidebarScrolling = sidebarHeight > windowHeight;
		needSidebarBottomOffset = sidebarHeight > maxContentScrollTop - contentBottom;
		maxSidebarScrollTop = sidebarHeight - windowHeight;

		if(scale > 1.0) {
			// duh
			$('#content')
				.css('margin-right', '');
			return;
		}

		$sidebar = $( '<div>' )
			.attr('id', 'content-sidebar')
			.css({
				position: 'fixed',
				overflow: 'hidden',
				background: 'white',
				top: '0',
				bottom: '0',
				right: '0',
				width: sidebarWidth
			})
			.insertAfter('#content')
			.html(
				'<svg width="'+sidebarWidth+'" height="'+sidebarHeight+'">' +
					'<g transform="scale('+scale+')">' +
						'<foreignObject width="'+contentWidth+'" height="'+contentHeight+'">' +
						'</foreignObject>' +
					'</g>' +
				'</svg>'
			);

		$sidebar.find( 'foreignObject' ).append(
			$('#content').clone().attr('id', '').css({
				'margin-right': 0,
				'margin-left': 0
			})
		);

		$svg = $sidebar.find('svg');

		// overlay to prevent clicking and hover effects
		var $overlay = $('<div>').css({
			position: 'absolute',
			left: 0,
			top: 0,
			right: 0,
			bottom: 0
		});
		// overlay indicator showing current scroll position
		$indicator = $('<div>').css({
			position: 'absolute',
			background: 'rgba(0,0,0,0.3)',
			left: 0,
			top: 0,
			width: sidebarWidth,
			height: windowHeight * scale
		});
		$closeButton = $('<div>').text('×').css({
			position: 'absolute',
			background: 'red',
			color: 'white',
			right: 0,
			top: 0,
			width: '1em',
			height: '1em',
			'text-align': 'center',
			'line-height': '1em'
		}).on('click', destroySidebar);

		$sidebar.append($overlay, $indicator, $closeButton);

		function clickHandler(e) {
			if(e.target == $closeButton[0]) return;
			
			e.stopPropagation();
			e.preventDefault();

			var ycoord = sidebarScrollTop + e.clientY;
			var scrollTop = (ycoord / scale) - windowHeight/2;

			$(document.documentElement).animate({ scrollTop: scrollTop }, 'fast');
			scrollHandler();
		}
		$sidebar[0].addEventListener( 'click', clickHandler, true );

		scrollHandler();
	}

	function destroySidebar() {
		if(!displaying) return;
		displaying = false;
		$('#content')
			.css('margin-right', '');
		$sidebar.remove();
		setupButton();
	}
	
	function setupButton() {
		$setupButton = $('<button>')
			.text('Render minimap')
			.css({
				position: 'absolute',
				bottom: '-2em',
				right: '0',
				height: '2em'
			})
			.on('click', function () {
				loadPromise.done(renderSidebar);
			})
			.appendTo('#right-navigation');
	}

	$( setupButton );
	loadPromise.done( function () {
		if(window.articleMapRenderOnLoad === true) {
			renderSidebar();
		}
	} );

	var state = 'top'; // || 'middle' || 'bottom'
	var sidebarScrollTop = 0;
	function scrollHandler() {
		if(!displaying) return;

		if(needSidebarScrolling && needSidebarBottomOffset && document.documentElement.scrollTop > contentBottom) {
			// ewwww
			$svg.css('margin-top', 0 );
			$indicator.css('margin-top', 0 );
		} else if(needSidebarScrolling ) {
			var scrollPercentage = document.documentElement.scrollTop / maxContentScrollTop;
			sidebarScrollTop = scrollPercentage * maxSidebarScrollTop;
			$svg.css('margin-top', -sidebarScrollTop );
			$indicator.css('margin-top', -sidebarScrollTop );
		}

		$indicator.css('top', document.documentElement.scrollTop * scale);

		if(document.documentElement.scrollTop < contentTop) {
			setTimeout( function () {
				var top = Math.max(0, contentTop - document.documentElement.scrollTop);
				$sidebar.css('top', top);
				state = (top ? 'top' : 'middle');
			}, 50 );
		} else if(needSidebarBottomOffset && document.documentElement.scrollTop > contentBottom) {
			setTimeout( function () {
				var bottom = Math.max(0, document.documentElement.scrollTop - contentBottom);
				$sidebar.css('bottom', bottom);
				// this condition is particularly messed up, surely we could do better?
				var shiftBy = (document.documentElement.scrollTop - contentTop - contentHeight + sidebarHeight);
				$sidebar.css('margin-top', -Math.max(0, shiftBy));
				state = (bottom || shiftBy ? 'bottom' : 'middle');
			}, 50 );
		} else {
			if(state !== 'middle') {
				$sidebar.css('top', 0);
				$sidebar.css('bottom', 0);
				$sidebar.css('margin-top', 0);
				state = 'middle';
			}
		}
	}
	$(window).on('scroll', scrollHandler);

	mw.loader.using('mediawiki.util', function () {
		$(window).on('resize', mw.util.debounce(100, function () {
			if(!displaying) return;
			destroySidebar();
			renderSidebar();
		}));
	});

} )();