Jump to content

MediaWiki:Gadget-Navigation.js

From Appropedia

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)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
const Navigation = {

	init( $content ) {
		$content.find( '.template-navigation-header' ).on( 'click', Navigation.toggle );
		$content.find( '.template-navigation-list' ).each( Navigation.trimList );
		$content.find( '.template-navigation-pages' ).each( Navigation.makePages );
		$content.find( '.template-navigation-download > .cdx-button' ).on( 'click', Navigation.toggleDownloads );
		$content.find( '.template-navigation-download table .cdx-button' ).on( 'click', Navigation.downloadFile );
		$content.find( '.template-navigation-audio' ).each( Navigation.makeAudioButtons );
	},

	toggle( event ) {
		if ( event.target.tagName === 'A' ) {
			return;
		}
		$( this ).siblings( '.template-navigation-content' ).toggle();
	},

	/**
	 * Trim lists of more than 15 items
	 * Example: [[NREMT Skillset]]
	 */
	trimList() {
		const $button = $( '<span>more</span>' ).css( 'cursor', 'pointer' ).on( 'click', () => $button.hide().siblings().show() );
		$( this ).find( 'ul, ol' ).each( ( index, list ) => {
			const $items = $( list ).children( 'li' );
			if ( $items.length > 15 ) {
				$items.eq( 10 ).nextAll().hide().last().after( $button );
			}
		} );
	},

	toggleDownloads() {
		const table = this.nextElementSibling;
		if ( window.getComputedStyle( table ).display === 'none' ) {
			table.style.display = 'table';
		} else {
			table.style.display = '';
		}
	},

	/**
	 * Get the file behind scenes and then download it
	 * In the case of booklets, turn the PDF into a booklet before downloading
	 */
	async downloadFile() {

		// Disable the button to prevent further clicks
		const button = this;
		button.style.pointerEvents = 'none';
		button.classList.remove( 'cdx-button--fake-button--enabled' );
		button.classList.add( 'cdx-button--fake-button--disabled' );
		const buttonText = button.textContent; // Save the text to restore it later
		button.textContent = 'Generating...';

		// Get the basic data
		const $navigation = $( button ).closest( '.template-navigation' );
		const title = $navigation.find( '.template-navigation-title' ).text();
		const subtitle = $navigation.find( '.template-navigation-subtitle' ).text();

		// Figure out the absolute path to the logo
		let logo = '';
		const $logo = $navigation.find( '.template-navigation-logo' );
		if ( $logo.length ) {
			logo = 'https://www.appropedia.org' + $logo.find( 'img' ).attr( 'src' );
		}

		// Figure out the main page
		let mainpage = '';
		const $titleLink = $navigation.find( '.template-navigation-title a' );
		if ( $titleLink.length ) {
			if ( $titleLink.hasClass( 'selflink' ) ) {
				mainpage = mw.config.get( 'wgPageName' );
			} else {
				mainpage = $titleLink.attr( 'title' );
			}
		}

		// Figure out the pages
		const pages = [];
		const $links = Navigation.getValidLinks( $navigation );
		$links.each( ( i, link ) => {
			const page = link.classList.contains( 'selflink' ) ? mw.config.get( 'wgPageName' ) : link.title;
			pages.push( page );
		} );

		// Make the download URL
		let script, params;
		if ( button.classList.contains( 'zim' ) ) {
			script = 'generateZIM';
			params = new URLSearchParams( {
				pages: pages.join( ',' ),
				title: title,
				description: subtitle,
				icon: logo,
			} );
		} else {
			script = 'generatePDF';
			params = new URLSearchParams( {
				pages: pages.join( ',' ),
				title: title,
				subtitle: subtitle,
				logo: logo,
				qrpage: mainpage
			} );
		}
		const url = 'https://www.appropedia.org/scripts/' + script + '.php?' + params.toString();

		// Get the file
		const result = await fetch( url );
		const bytes = await result.arrayBuffer();
		const type = result.headers.get( 'Content-Type' );
		const contentDisposition = result.headers.get( 'Content-Disposition' );
		const matches = contentDisposition.match( /filename *= *([^;]+)/ );
		const name = matches[1];
		let file = { bytes: bytes, type: type, name: name };

		// Convert the file into a booklet
		if ( button.classList.contains( 'booklet' ) ) {
			file = await Navigation.makeBooklet( file );
		}

		// Download the file
		const blob = new Blob( [ file.bytes ], { type: file.type } );
		const href = URL.createObjectURL( blob );
		const a = document.createElement( 'a' );
		a.href = href;
		a.download = file.name;
		document.body.appendChild( a );
		a.click();
		document.body.removeChild( a );
		URL.revokeObjectURL( href );

		// Re-enable the button
		button.style.pointerEvents = '';
		button.textContent = buttonText;
		button.classList.remove( 'cdx-button--fake-button--disabled' );
		button.classList.add( 'cdx-button--fake-button--enabled' );
	},

	/**
	 * This method transforms a PDF into a booklet
	 * The code is based on bookletize.js by Jeffrey Yoo Warren (https://jywarren.github.io/bookletize.js)
	 * which uses PDFLib.js (https://pdf-lib.js.org)
 	 */
	/* global PDFLib */
	async makeBooklet( file ) {
		await mw.loader.getScript( 'https://cdnjs.cloudflare.com/ajax/libs/pdf-lib/1.17.1/pdf-lib.min.js' );
		const bookletDoc = await PDFLib.PDFDocument.create();
		const fileDoc = await PDFLib.PDFDocument.load( file.bytes );
		const { width, height } = await fileDoc.getPages()[0].getSize();

		// Pad the file PDF to a multiple of 4 by adding empty pages
		let pageCount = await fileDoc.getPageCount();
		while ( pageCount % 4 ) {
			let page = fileDoc.addPage( [ width, height ] );
			page.drawText( '' ); // The page needs to have "some text" or bookletDoc.save() fails
			pageCount++;
		}

		// Iterate through, plucking out 4 pages at a time and inserting into new sheet
		const filePages = await fileDoc.getPages();
		for ( let p = 0; p < pageCount; p += 4 ) {

			// Double width, same height
			const bookletPage = bookletDoc.addPage( [ width * 2, height ] );
			const bookletPage2 = bookletDoc.addPage( [ width * 2, height ] );

			// If the file is more than 16 pages long, we make a perfect binding, else a saddle stitch
			if ( pageCount > 16 ) {
				await Navigation.getPage( filePages.length - 1, { x: 0, y: 0 }, filePages, bookletDoc, bookletPage );
				await Navigation.getPage( 0, { x: width, y: 0 }, filePages, bookletDoc, bookletPage );
				await Navigation.getPage( 0, { x: 0, y: 0 }, filePages, bookletDoc, bookletPage2 );
				await Navigation.getPage( filePages.length - 1, { x: width, y: 0 }, filePages, bookletDoc, bookletPage2 );
			} else {
				await Navigation.getPage( 3, { x: 0, y: 0 }, filePages, bookletDoc, bookletPage );
				await Navigation.getPage( 0, { x: width, y: 0 }, filePages, bookletDoc, bookletPage );
				await Navigation.getPage( 0, { x: 0, y: 0 }, filePages, bookletDoc, bookletPage2 );
				await Navigation.getPage( 0, { x: width, y: 0 }, filePages, bookletDoc, bookletPage2 );
			}
		}
		const bookletBytes = await bookletDoc.save();
		return { bytes: bookletBytes, type: file.type, name: file.name };
	},

	/**
	 * Helper method to fetch the next page from the file stack and remove it
	 */
	async getPage( filePosition, placement, filePages, _bookletDoc, _bookletPage ) {
		if ( filePages.length > 0 ) {
			var embeddedPage = await _bookletDoc.embedPage( filePages.splice( filePosition, 1 )[0] );
			_bookletPage.drawPage( embeddedPage, placement );
		}
	},

	makeAudioButtons() {

		// Make the play button
		const playButtonTitle = '<title>Play</title>';
		const playButtonPath = '<path fill-rule="evenodd" d="M424.4 214.7L72.4 6.6C43.8-10.3 0 6.1 0 47.9V464c0 37.5 40.7 60.1 72.4 41.3l352-208c31.4-18.5 31.5-64.1 0-82.6z"></path>';
		const playButtonSVG = '<svg width="40" height="40" viewBox="0 0 448 512">' + playButtonTitle + playButtonPath + '</svg>';
		const $playButton = $( '<span class="template-navigation-audio-button template-navigation-audio-button-play">' + playButtonSVG + '</span>' );
		$playButton.on( 'click', Navigation.readAloud );

		// Make the pause button
		const pauseButtonTitle = '<title>Pause</title>';
		const pauseButtonPath = '<path fill-rule="evenodd" d="M144 479H48c-26.5 0-48-21.5-48-48V79c0-26.5 21.5-48 48-48h96c26.5 0 48 21.5 48 48v352c0 26.5-21.5 48-48 48zm304-48V79c0-26.5-21.5-48-48-48h-96c-26.5 0-48 21.5-48 48v352c0 26.5 21.5 48 48 48h96c26.5 0 48-21.5 48-48z"></path>';
		const pauseButtonSVG = '<svg width="40" height="40" viewBox="0 0 448 512">' + pauseButtonTitle + pauseButtonPath + '</svg>';
		const $pauseButton = $( '<span class="template-navigation-audio-button template-navigation-audio-button-pause">' + pauseButtonSVG + '</span>' ).hide();
		$pauseButton.on( 'click', Navigation.pauseReadAloud );

		// Make the back button
		const backButtonTitle = '<title>Back</title>';
		const backButtonPath = '<path fill-rule="evenodd" d="M64 468V44c0-6.6 5.4-12 12-12h48c6.6 0 12 5.4 12 12v176.4l195.5-181C352.1 22.3 384 36.6 384 64v384c0 27.4-31.9 41.7-52.5 24.6L136 292.7V468c0 6.6-5.4 12-12 12H76c-6.6 0-12-5.4-12-12z"></path>';
		const backButtonSVG = '<svg width="30" height="30" viewBox="0 0 448 512">' + backButtonTitle + backButtonPath + '</svg>';
		const $backButton = $( '<span class="template-navigation-audio-button template-navigation-audio-button-back">' + backButtonSVG + '</span>' );

		// Make the next button
		const nextButtonTitle = '<title>Next</title>';
		const nextButtonPath = '<path fill-rule="evenodd" d="M384 44v424c0 6.6-5.4 12-12 12h-48c-6.6 0-12-5.4-12-12V291.6l-195.5 181C95.9 489.7 64 475.4 64 448V64c0-27.4 31.9-41.7 52.5-24.6L312 219.3V44c0-6.6 5.4-12 12-12h48c6.6 0 12 5.4 12 12z"></path>';
		const nextButtonSVG = '<svg width="30" height="30" viewBox="0 0 448 512">' + nextButtonTitle + nextButtonPath + '</svg>';
		const $nextButton = $( '<span class="template-navigation-audio-button template-navigation-audio-button-next">' + nextButtonSVG + '</span>' );

		$( this ).append( $backButton, $playButton, $pauseButton, $nextButton );
	},

	async readAloud() {
		const $playButton = $( this );
		const $pauseButton = $playButton.siblings( '.template-navigation-audio-button-pause' );
		$playButton.hide();
		$pauseButton.show();

		if ( window.speechSynthesis.paused ) {
			window.speechSynthesis.resume();
			return;
		}

		try {
			const title = mw.config.get( 'wgPageName' );
			const params = {
				action: 'query',
				titles: title,
				prop: 'extracts',
				explaintext: true,
				exsectionformat: 'plain',
				formatversion: 2
			};
			const response = await new mw.Api().get( params );
			const text = response.query.pages[0].extract;
			const utterance = new window.SpeechSynthesisUtterance( text );
			window.speechSynthesis.speak( utterance );
		} catch ( error ) {
			console.log( error );
		}
	},

	pauseReadAloud() {
		const $pauseButton = $( this );
		const $playButton = $pauseButton.siblings( '.template-navigation-audio-button-play' );
		$pauseButton.hide();
		$playButton.show();
		window.speechSynthesis.pause();
	},

	makePages() {
		const $navigation = $( this ).closest( '.template-navigation' );
		const $links = Navigation.getValidLinks( $navigation );
		let prevLink, nextLink, index;
		$links.each( ( i, link ) => {
			if ( link.classList.contains( 'selflink' ) ) {
				index = i;
				prevLink = $links[ index - 1 ];
				nextLink = $links[ index + 1 ];
				return;
			}
		} );
		if ( index === undefined ) {
			return;
		}
		let pages = '';
		if ( prevLink ) {
			const prevButton = document.createElement( 'a' );
			prevButton.href = prevLink.href;
			prevButton.title = 'Go to the previous page';
			prevButton.textContent = '◄';
			pages += prevButton.outerHTML;
		}
		if ( $links.length > 20 ) {
			const currentPage = index + 1;
			const totalPages = $links.length;
			const pagesText = document.createElement( 'span' );
			pagesText.classList.add( 'text' );
			pagesText.textContent = 'Page ' + currentPage + '/' + totalPages;
			pages += pagesText.outerHTML;
		} else {
			$links.each( ( i, link ) => {
				const pageButton = document.createElement( 'a' );
				pageButton.textContent = '■';
				pageButton.classList.add( 'page' );
				if ( link.classList.contains( 'selflink' ) ) {
					pageButton.href = mw.util.getUrl();
					pageButton.title = mw.config.get( 'wgPageName' );
					pageButton.classList.add( 'current' );
				} else {
					pageButton.href = link.href;
					pageButton.title = link.title;
				}
				pages += pageButton.outerHTML;
			} );
		}
		if ( nextLink ) {
			const nextButton = document.createElement( 'a' );
			nextButton.href = nextLink.href;
			nextButton.title = 'Go to the next page';
			nextButton.textContent = '►';
			pages += nextButton.outerHTML;
		}
		$navigation.find( '.template-navigation-pages' ).html( pages );
	},

	getValidLinks( $navigation ) {
		return $navigation.find( '.template-navigation-title a' ).add( '.template-navigation-list a' ).filter( ( index, link ) => {
			if ( link.classList.contains( 'external' ) ) {
				return false; // Skip external pages
			}
			if ( link.classList.contains( 'new' ) ) {
				return false; // Skip non-existent pages
			}
			return true;
		} );
	}
};

mw.hook( 'wikipage.content' ).add( Navigation.init );
Cookies help us deliver our services. By using our services, you agree to our use of cookies.