MediaWiki:Gadget-Global-MiniEdit.js

From mediawiki.org

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.
/**
 * MiniEdit enables quick and simple edits without leaving the page
 * Documentation: https://www.mediawiki.org/wiki/MiniEdit
 * License: GNU General Public License 3 or later (http://www.gnu.org/licenses/gpl-3.0.html)
 * Author: Felipe Schenone (User:Sophivorus)
 */
var MiniEdit = {

	/**
	 * Will hold the wikitext of the current page
	 */
	pageWikitext: '',

	/**
	 * Initialization script
	 */
	init: function () {

		// Only init when viewing
		var action = mw.config.get( 'wgAction' );
		if ( action !== 'view' ) {
			return;
		}

		// Only init in useful namespaces
		var namespaces = [ 0, 2, 4, 12, 14 ];
		var namespace = mw.config.get( 'wgNamespaceNumber' );
		var talk = namespace % 2 === 1; // Talk pages always have odd namespaces
		if ( !namespaces.includes( namespace ) && !talk ) {
			return;
		}

		// Only init in wikitext pages
		var model = mw.config.get( 'wgPageContentModel' );
		if ( model !== 'wikitext' ) {
			return;
		}

		// Select only paragraphs that are direct children
		// to reduce the chances of matching paragraphs that come from templates
		var selector = '#mw-content-text > .mw-parser-output > p';
		if ( mw.config.get( 'skin' ) === 'minerva' ) {
			selector = '#mw-content-text > .mw-parser-output > section > p';
		}
		$( selector ).each( MiniEdit.addEditButton );
	},

	/**
	 * Add an edit button
	 */
	addEditButton: function () {
		var $paragraph = $( this );

		// Skip empty paragraphs
		if ( !$paragraph.text().trim() ) {
			return;
		}

		// Make the edit button
		var path = '<path fill="currentColor" d="M16.77 8l1.94-2a1 1 0 0 0 0-1.41l-3.34-3.3a1 1 0 0 0-1.41 0L12 3.23zm-5.81-3.71L1 14.25V19h4.75l9.96-9.96-4.75-4.75z"></path>';
		var icon = '<svg width="14" height="14" viewBox="0 0 20 20">' + path + '</svg>';
		var $button = $( '<span class="miniedit-button noprint">' + icon + '</span>' );
		$button.css( { 'color': '#a2a9b1', 'cursor': 'pointer' } );
		$button.on( 'click', MiniEdit.onEditButtonClick );

		// Only show the button when the user hovers over the paragraph
		// On mobile devices there's no hover event, so we just skip this part and show the button always
		if ( window.innerWidth > 800 ) {
			$button.hide();
			$paragraph.on( 'mouseenter', function () { $button.show(); } );
			$paragraph.on( 'mouseleave', function () { $button.hide(); } );
			$button.on( 'mouseenter', function () { $( this ).css( 'color', '#202122' ); } );
			$button.on( 'mouseleave', function () { $( this ).css( 'color', '#a2a9b1' ); } );
		}

		// Add to the DOM
		$paragraph.append( ' ', $button );
	},

	/**
	 * Handle a click on an edit button
	 */
	onEditButtonClick: function () {
		var $button = $( this ).closest( '.miniedit-button' );
		var $paragraph = $button.parent();

		// Save the original paragraph in case we need to restore it later
		// For some reason the hover events on the button are not getting cloned, so we remake the button
		var $original = $paragraph.clone( true );
		$original.find( '.miniedit-button' ).remove();
		MiniEdit.addEditButton.call( $original );

		// If pageWikitext is set, it means that the dependencies were already loaded by a previous click
		if ( MiniEdit.pageWikitext ) {
			MiniEdit.addEditForm( $paragraph, $original );
			return;
		}

		// If we reach this point, we need to load the dependencies
		// First, we replace the button for a loading spinner
		// to prevent further clicks and to signal the user that something's happening
		var $spinner = MiniEdit.getSpinner();
		$button.replaceWith( $spinner );

		// Then we load dependencies
		$.when(
			MiniEdit.getPageWikitext(),
			MiniEdit.getMessages( 'en' )
		).done( function () {
			// Note the special treatment of getMessages( pageLanguage )
			// because it may fail if a translation doesn't exist yet
			// and because its success callback needs to run AFTER getMessages( 'en' )
			var pageLanguage = mw.config.get( 'wgPageContentLanguage' );
			MiniEdit.getMessages( pageLanguage ).always( function () {
				MiniEdit.addEditForm( $paragraph, $original );
			} );
		} );
	},

	/**
	 * Add an edit form
	 */
	addEditForm: function ( $paragraph, $original ) {
		// If no relevant wikitext for the element is found, fallback to regular edit
		var wikitext = MiniEdit.getParagraphWikitext( $paragraph );
		if ( !wikitext ) {
			var $section = MiniEdit.getSection( $paragraph );
			var sectionNumber = $section ? 1 + $section.prevAll( ':header' ).length : 0;
			var editUrl = mw.util.getUrl( null, { action: 'edit', section: sectionNumber } );
			window.location.href = editUrl;
			return;
		}

		// Make the form
		var wikitextInput = new OO.ui.MultilineTextInputWidget( { name: 'wikitext', value: wikitext, autofocus: true, autosize: true } );
		var wikitextLayout = new OO.ui.HorizontalLayout( { items: [ wikitextInput ] } );
		var summaryInput = new OO.ui.TextInputWidget( { name: 'summary', placeholder: mw.msg( 'miniedit-form-summary' ) } );
		var summaryLayout = new OO.ui.HorizontalLayout( { items: [ summaryInput ] } );

		// Anons can't mark edits as minor
		if ( !mw.user.isAnon() ) {
			var minorCheckbox = new OO.ui.CheckboxInputWidget( { name: 'minor' } );
			var minorLayout = new OO.ui.FieldLayout( minorCheckbox, { label: mw.msg( 'miniedit-form-minor' ), align: 'inline' } );
			summaryLayout.addItems( [ minorLayout ] );
			summaryLayout.$element.find( '.oo-ui-fieldLayout' ).css( 'vertical-align', 'text-bottom' ); // Minor alignment fix
		}

		// Add the buttons
		var publishButton = new OO.ui.ButtonInputWidget( { label: mw.msg( 'miniedit-form-publish' ), flags: [ 'primary', 'progressive' ] } );
		var cancelButton = new OO.ui.ButtonInputWidget( { label: mw.msg( 'miniedit-form-cancel' ), flags: 'destructive', framed: false } );
		var formLayout = new OO.ui.FormLayout( { items: [ wikitextLayout, summaryLayout, publishButton, cancelButton ] } );

		// CSS tweaks
		formLayout.$element.css( 'overflow', 'hidden' );
		wikitextInput.$element.css( { 'font-family': 'monospace', 'max-width': '100%' } );

		// Add to the DOM
		var $form = formLayout.$element;
		$paragraph.html( $form );

		// Handle a submission
		publishButton.on( 'click', MiniEdit.onSubmit, [ $paragraph, $original, $form, wikitext, publishButton, cancelButton ] );

		// Handle a cancel
		cancelButton.on( 'click', function () {
			$paragraph.replaceWith( $original );
		} );
	},

	/**
	 * Handle a submission
	 */
	onSubmit: function ( $paragraph, $original, $form, oldWikitext, publishButton, cancelButton ) {
		var newWikitext = $form.find( 'textarea[name="wikitext"]' ).val();

		// If no changes were made, just restore the original element
		if ( oldWikitext === newWikitext ) {
			$paragraph.replaceWith( $original );
			return;
		}

		// Disable the buttons to prevent further clicks and to signal the user that something's happening
		publishButton.setDisabled( true );
		cancelButton.setDisabled( true );

		// Get the rest of the form data
		var summary = $form.find( 'input[name="summary"]' ).val();
		var minor = $form.find( 'input[name="minor"]' ).prop( 'checked' );

		// Fix excessive line breaks
		newWikitext = newWikitext.trim();
		newWikitext = newWikitext.replace( /\n\n\n+/g, '\n\n' );

		// If the paragraph was deleted, remove also any trailing newlines
		if ( !newWikitext ) {
			oldWikitext = oldWikitext.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ); // Escape special characters
			oldWikitext = new RegExp( oldWikitext + '\n+' );
		}

		MiniEdit.pageWikitext = MiniEdit.pageWikitext.replace( oldWikitext, newWikitext );
		var params = {
			'action': 'edit',
			'title': mw.config.get( 'wgPageName' ),
			'text': MiniEdit.pageWikitext,
			'minor': minor,
			'summary': MiniEdit.makeSummary( summary, $form, newWikitext ),
			'tags': mw.config.get( 'miniedit-tag' )
		};
		new mw.Api().postWithEditToken( params ).done( function () {
			MiniEdit.onSuccess( $paragraph, newWikitext );
		} );
	},

	/**
	 * Callback on successful edits
	 */
	onSuccess: function ( $paragraph, newWikitext ) {
		if ( !newWikitext ) {
			$paragraph.remove();
			return;
		}
		var params = {
			'action': 'parse',
			'title': mw.config.get( 'wgPageName' ),
			'text': newWikitext,
			'formatversion': 2,
			'prop': 'text',
			'disablelimitreport': true,
		};
		new mw.Api().get( params ).done( function ( data ) {
			var text = data.parse.text;
			var $html = $( text );
			var $paragraphs = $html.find( 'p' );
			$paragraph.replaceWith( $paragraphs );
			$paragraphs.each( MiniEdit.addEditButton );
		} );
	},

	/**
	 * Get the wikitext of the current page
	 */
	getPageWikitext: function () {
		var params = {
			'page': mw.config.get( 'wgPageName' ),
			'action': 'parse',
			'prop': 'wikitext',
			'formatversion': 2,
		};
		return new mw.Api().get( params ).done( function ( data ) {
			MiniEdit.pageWikitext = data.parse.wikitext;
		} );
	},

	/**
	 * Get messages from the Wikimedia repository
	 */
	getMessages: function ( language ) {
		return $.get( '//gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/gadgets/MiniEdit/+/master/i18n/' + language + '.json?format=text', function ( data ) {
			var json = MiniEdit.decodeBase64( data );
			var messages = JSON.parse( json );
			delete messages[ '@metadata' ];
			mw.messages.set( messages );
		} );
	},

	/**
	 * Helper method to get the relevant wikitext that corresponds to a given paragraph
	 *
	 * This is actually the heart of the tool
	 * It's a heuristic method to try to find the relevant wikitext
	 * that corresponds to the paragraph being edited
	 * Since wikitext and HTML are different markups
	 * the only place where they are the same is in plain text fragments
	 * so we find the longest plain text fragment in the HTML
	 * then we search for that same fragment in the wikitext
	 * and return the entire line of wikitext containing that fragment
	 * or null if anything goes wrong
	 *
	 * @param {jQuery object} jQuery object representing the DOM element being edited
	 * @return {string|null} Wikitext of the paragraph being edited, or null if it can't be found
	 */
	getParagraphWikitext: function ( $paragraph ) {
		// The longest text node has the most chances of being unique
		var text = MiniEdit.getLongestText( $paragraph );

		// Some paragraphs may not have text nodes at all
		if ( !text ) {
			return;
		}

		// Match all lines that contain the text
		text = text.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ); // Escape special characters
		var regexp = new RegExp( '.*' + text + '.*', 'g' );
		var matches = MiniEdit.pageWikitext.match( regexp );

		// This may happen if the paragraph comes from a template
		if ( !matches ) {
			return;
		}

		// This may happen if the longest text is very short and repeats somewhere else
		if ( matches.length > 1 ) {
			return;
		}

		// We got our relevant wikitext line
		return matches[0];
	},

	/**
	 * Helper method to get the text of the longest text node
	 */
	getLongestText: function ( $paragraph ) {
		var text = '';
		var $textNodes = $paragraph.contents().filter( function () {
			return this.nodeType === Node.TEXT_NODE;
		} );
		$textNodes.each( function () {
			var nodeText = $( this ).text().trim();
			if ( nodeText.length > text.length ) {
				text = nodeText;
			}
		} );
		return text;
	},

	/**
	 * Helper method to build a helpful edit summary
	 */
	makeSummary: function ( summary, $paragraph, wikitext ) {
		if ( !summary ) {
			var action = wikitext ? 'edit' : 'delete';
			summary = mw.msg( 'miniedit-summary-' + action );
		}
		var $section = MiniEdit.getSection( $paragraph );
		if ( $section ) {
			var section = $section.find( '.mw-headline' ).attr( 'id' ).replaceAll( '_', ' ' );
			summary = '/* ' + section + ' */ ' + summary;
		}
		var page = mw.config.get( 'miniedit-page', 'mw:MiniEdit' );
		summary += ' [[' + page + '| #MiniEdit]]'; // For https://hashtags.wmcloud.org
		return summary;
	},

	/**
	 * Helper method to find the closest section
	 * by traversing back and up the DOM tree
	 *
	 * @param {jQuery object} Starting element
	 * @return {jQuery object} Closest section
	 */
	getSection: function ( $element ) {
		if ( $element.attr( 'id' ) === 'mw-content-text' ) {
			return;
		}
		if ( $element.is( ':header' ) ) {
			return $element;
		}
		var $previous = $element.prevAll( ':header' ).first();
		if ( $previous.length ) {
			return $previous;
		}
		var $parent = $element.parent();
		return MiniEdit.getSection( $parent );
	},

	/**
	 * Helper method to get a spinner (loading) icon
	 */
	 getSpinner: function () {
		var spinner = '<svg class="miniedit-spinner" width="14" height="14" viewBox="0 0 100 100">';
		spinner += '<rect fill="#555555" height="10" rx="5" ry="5" width="28" x="67" y="45" opacity="0.000" transform="rotate(-90 50 50)" />';
		spinner += '<rect fill="#555555" height="10" rx="5" ry="5" width="28" x="67" y="45" opacity="0.125" transform="rotate(-45 50 50)" />';
		spinner += '<rect fill="#555555" height="10" rx="5" ry="5" width="28" x="67" y="45" opacity="0.250" transform="rotate(0 50 50)" />';
		spinner += '<rect fill="#555555" height="10" rx="5" ry="5" width="28" x="67" y="45" opacity="0.375" transform="rotate(45 50 50)" />';
		spinner += '<rect fill="#555555" height="10" rx="5" ry="5" width="28" x="67" y="45" opacity="0.500" transform="rotate(90 50 50)" />';
		spinner += '<rect fill="#555555" height="10" rx="5" ry="5" width="28" x="67" y="45" opacity="0.625" transform="rotate(135 50 50)" />';
		spinner += '<rect fill="#555555" height="10" rx="5" ry="5" width="28" x="67" y="45" opacity="0.750" transform="rotate(180 50 50)" />';
		spinner += '<rect fill="#555555" height="10" rx="5" ry="5" width="28" x="67" y="45" opacity="0.875" transform="rotate(225 50 50)" />';
		spinner += '</svg>';
		var $spinner = $( spinner );
		var degrees = 0;
		setInterval( function () {
			degrees += 45;
			$spinner.css( 'transform', 'rotate(' + degrees + 'deg)' );
		}, 100 );
		return $spinner;
	},

	/**
	 * Helper method to decode base64 strings
	 * See https://stackoverflow.com/questions/30106476
	 *
	 * @param {string} Encoded string
	 * @return {string} Decoded string
	 */
	decodeBase64: function ( string ) {
		return decodeURIComponent( window.atob( string ).split( '' ).map( function ( character ) {
			return '%' + ( '00' + character.charCodeAt( 0 ).toString( 16 ) ).slice( -2 );
		} ).join( '' ) );
	}
};

mw.loader.using( [
	'mediawiki.api',
	'mediawiki.user',
	'mediawiki.util',
	'oojs-ui-core',
	'oojs-ui-widgets'
], MiniEdit.init );