MediaWiki:Guidedtour-lib.js

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.
/**
 * Library to easily create and maintain translatable guided tours.
 *
 * Important note: This script must be loaded before the entire page is ready because
 * otherwise GuidedTours will try to start a tour although it is not defined yet.
 *
 * @author Bene*
 * 
 * Temporary note: 
 * This version has some temporary fixes that will be resolved more thoroughly very
 * shortly. In the meantime, use `actionBtn2` instead of `actionBtn` in step options
 * if you want a click action to be completed automatically for a step.
 */
( function( $, mw, gt, wb ) {
	'use strict';

	$.ajax( {
		async: false,
		url: mw.util.wikiScript() + '?title=MediaWiki:JQuery.overlay.js&action=raw&ctype=text/javascript',
		dataType: 'script'
	} );

	mw.util.addCSS(
		'.tipsy { z-index: 100000006; } /* tipsy should stay visible */ \
		.ui-suggester-list.ui-ooMenu { z-index: 1000000061; } /* suggestions should stay visible */ \
		#mw-spinner-guidedtour { position: absolute; height: 100%; background-color: rgba(0, 0, 0, 0.1); z-index: 1000000; } /* the spinner */ \
		#bodyContent { z-index: auto; } /* Ugly hack to make the overlay work again */'
	);

	var defaultStep = {
		overlay: false,
		closeOnClickOutside: false,
		position: 'bottom', // only when attached
		actionBtn: '',
		actionBtn2: '', // Hack! Using separate property to prevent default click on action btn triggered in guiders library
		actionComplete: false,
		xButton: false
	};

    /**
	 * Shows a spinner covering the whole page.
	 */
	function showSpinner() {
		$( 'body' ).css( 'overflow', 'hidden' );
		mw.loader.using( [ 'jquery.spinner' ], function() {
			$.createSpinner( { size: 'large', type: 'block', id: 'guidedtour' } ).prependTo( 'body' );
		} );
    }
    
    /**
	 * Removes the data from the current entity and adds the new data.
	 * When finished reloads the site with data=ok.
	 *
	 * @param {string} tourName
	 * @param {object} newData
	 */
	function removeData( tourName, newData ) {
		mw.loader.using( 'mediawiki.api', function() {
			new mw.Api()
			.postWithEditToken( {
				action: 'wbeditentity',
				id: mw.config.get( 'wbEntityId' ),
				clear: true,
				data: JSON.stringify( newData ),
				summary: 'Clearing data for [[Wikidata:Tours#' + tourName + '|' + tourName + ']] tour'
			} )
			.done( function() {
				location.href += '&data=ok';
			} );
		} );
	}

    /**
	 * Parses the given page and returns the sections used for the single steps of the tour.
	 *
	 * @param {string} pageName
	 * @param {string} language
	 * @return {array}
	 */
	function getSections( pageName, language ) {
		var data = JSON.parse( $.ajax( {
			dataType: 'json',
			url: '/w/api.php', // this is ugly but necessary if we want to execute the tour on testwikidata
			data: {
				'action': 'parse',
				'format': 'json',
				'page': pageName + ( language === 'en' ? '' : '/' + language ),
				'prop': 'text|sections',
				'disablepp': true,
				'disabletoc': true
			},
			async: false
		} ).responseText );

		if ( data.error ) {
			if ( language !== 'en' ) {
				location.href += '&uselang=en';
			} else {
				console.log( data.error );
				return false;
			}
		}

		var rawSections = data.parse.text['*'].split( /<h2[^>]*>((?!<\/h2>).)*<\/h2>/gi ),
			titles = data.parse.sections,
			sections = [];

		for ( var i = 0; i < titles.length; i++ ) {
			sections.push( {
				title: titles[i].line,
				description: rawSections[i * 2 + 2]
			} );
		}

		return sections;
	}

    /**
	 * Builds the tour's options with steps parsed form the given page.
	 *
	 * @param {string} tourName
	 * @param {string} tourEntityId
	 * @param {object} options
	 *
	 * @return {object}
	 */
    function buildOptionsFromPage( tourName, tourEntityId, options ) {
		var language = mw.config.get( 'wgUserLanguage' ),
			sections = getSections( options.pageName, language );

		$.extend( options, {
			name: tourName,
			shouldLog: true
		} );

		$.each( sections, function( i ) {
		    var step = options.steps[i] = $.extend( {}, defaultStep, options.steps[i], sections[i] ),
		        _onShow = step.onShow;
			$.extend( options.steps[i], {
				onShow: function() {
					if ( typeof _onShow === 'function' ) {
						_onShow();
					}
					if (step.actionComplete) {
						// Action already completed
						// Hack! Re-add the the click handler for moving to next slide. 
						// The default click gets removed when mutation observer is added, so we can wait for DOM before triggering next step
						$('.guidedtour-next-button').off('click').on('click', function() {
							mw.libs.guiders.next();
						});
					} else if ( step.actionBtn2 ) {
						var nextStep = options.steps[i+1];
						
						// Hack! Intercept the click to prevent going to next slide before DOM is ready.
						// Instead, trigger the action button click and let the mutation observer detect the change and advance the slide
						// Proper fix requires small changes to guiders library
						
						$('.guidedtour-next-button').off('click').one('click', function() {
							$(step.actionBtn2).click();
						});
						
						if( nextStep && nextStep.attachTo && $(nextStep.attachTo).length === 0) {
							// The attach element for next step is not in the DOM yet, add mutation observer
							step.observer = newElementObserver(nextStep.attachTo, function() {
								step.observer.disconnect();
								step.actionComplete = true;
								return mw.libs.guiders.next();
							})
						} else {
							// step has an action button, but there is no attach in the next slide or it's element is already visible
							// add the click event to advance the slide from an action button click, as there are no mutations to trigger it
							$(step.actionBtn2).one('click', function () {
								step.actionComplete = true;
								mw.libs.guiders.next();
							})
						}
					}
					
					if ( step.overlay ) {
						console.log($( step.overlay ));
						$( step.overlay ).overlay();
					} else {
						$.overlay.remove();
					}
				}
			} );

			// change the defaults for the end of the tour
			if ( i === sections.length - 1 ) {
				$.extend( options.steps[i], {
					overlay: false,
					//closeOnClickOutside: true,
					buttons: [ { action: 'end' } ]
				} );
			}
		} );

		if ( location.href.indexOf( 'uselang=en' ) > -1 ) {
			options.steps.unshift( $.extend( {}, defaultStep, {
				title: 'Not your language',
				description: 'This tour is not available in your language. You may proceed using English or ' +
					'<a href="' + mw.util.getUrl( options.pageName ) + '">translate it into your language</a>.'
			} ) );
		}

		return options;
	}

    /**
	 * Defines the tour with the given options using the TourBuilder.
	 *
	 * @param {object} options
	 */
	function defineTour( options ) {
		var tour = new gt.TourBuilder( {
			name: options.name,
			shouldLog: true
		} );

		tour.firstStep( $.extend( {
			name: 'step0'
		}, options.steps[0] ) ).next( 'step1' );

		for ( var i = 1; i < options.steps.length - 1; i++ ) {
			tour.step( $.extend( {
				name: 'step' + i
			}, options.steps[i] ) ).next( 'step' + ( i + 1 ) ).back(  'step' + ( i - 1 ) );
		}

		tour.step( $.extend( {
			name: 'step' + ( options.steps.length - 1 )
		}, options.steps[options.steps.length - 1] ) ).back( 'step' + ( options.steps.length - 2 ) );
	}
    
    /**
	 * Creates mutation observer which run the callback when an element is added
	 * that matches the selector
     * 
	 * @param {string} selector
     * @param {function} callback
     * 
     * @return {object}
	 */
	function newElementObserver(selector, callback) {
		var observer = new MutationObserver(function (mutationList) {
			for(var mutation of mutationList) {
				for(var node of mutation.addedNodes) {
					if($(node).is(selector) ) {
						// The node matching the selector has been added
						if (callback) callback();
						return;
					}
				}
			}
		});
		var targetNode = $("body").get(0);
		var observerOptions = {
			childList: true,
			subtree: true
		}
		observer.observe(targetNode, observerOptions);
		return observer;
	}

    /**
	 * Checks if the tour name and entity id are correct. Also checks if the data
	 * has been prepared and clears or adds missing data. If everything is ok,
	 * starts the tour.
	 */
	gt.init = function( tourName, tourEntityId, newData, options ) {
		// check for the correct page
		if ( !wb || location.href.indexOf( 'tour=' + tourName ) < 0 ||
			mw.config.get( 'wbEntityId' ) !== tourEntityId || mw.config.get( 'wbIsEditView' ) !== true
		) {
			return;
		}

		// check for emptiness
		if ( !gt.hasQuery( { 'data' : 'ok' } ) ) {
			showSpinner();
			removeData( tourName, newData );
			return;
		}

		// launch the tour
		defineTour( buildOptionsFromPage( tourName, tourEntityId, options ) );
	};

} )( jQuery, mediaWiki, mediaWiki.guidedTour, wikibase );