MediaWiki:Gadget-im.js

Iz Medžuviki, svobodnoj enciklopedije
Jump to navigation Jump to search

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.
/*
 * Interslavic input method
 * Based on:
 * - https://de.wikipedia.org/wiki/Benutzer:Jowereit/typografie.js
 * - https://de.wikipedia.org/wiki/Benutzer:Schnark/js/veAutocorrect.js
 */
( function() {
	// Main form selectors
	var mainFormFields = [
		'#wpTextbox1',
		'#wpText',
		'#wpUploadDescription'
	]

	// Ignored selectors
	var ignoredFields = [
		'#wpUserEmail',
		'#wpEmail',
		'#mw-input-wpemailaddress',
		'#wpCaptchaWord'
	];

	// Re-used variables
	var isAnonymous = mw.config.get( 'wgUserName' ) === null;
	var api;
	var defaultStorageKey = '__im';
	var imEnabled = true;
	var mainTextbox;
	var veCommandCount = 0;
	var imVeFuncs = {};

	// Menu configuration
	var imMenu = {
		title: 'Metoda vpisa',
		description: 'Koristajte skračeńja dlja vpisa razširenoj latinice i kirilice.',
		help: 'Pomoč',
		helpLink: '/wiki/Project:Pomoč/Metoda_vpisa',
		enable: 'Vključiti',
		disable: 'Odključiti'
	}

	// Polyfill for Element.prototype.matches
	if ( !Element.prototype.matches ) {
		Element.prototype.matches = Element.prototype.msMatchesSelector || Element.prototype.webkitMatchesSelector;
	}
	
	/**
	 * Restore the unused letters in code editor.
	 * 
	 * @param self HTML element.
	 * @param e Event information.
	 * @param isVE Whether function is called from VisualEditor.
	 */
	function imShiftBackspace( self, e, isVE ) {
		// Visual Editor
		if ( isVE ) return;
	
		// WikiEditor
		if ( !e.shiftKey ) return;
		var lastEntry = self.lastEntry( 1 );
	
		// Look into imReplaceUnused
		if ( lastEntry === 'Ј' || lastEntry === 'ј' ) {
			self.insert( ( lastEntry === 'Ј' ? 'Й': 'й' ), 1 );
		}
		
		// Look into imReplaceCyrlI
		if ( lastEntry === 'И' || lastEntry === 'и' ) {
			self.insert( ( lastEntry === 'И' ? 'І': 'і' ), 1 );
		}
	}
	
	/**
	 * Print letters with hachek on typing x.
	 * 
	 * @param self HTML element.
	 * @param e Event information.
	 * @param isVE Whether function is called from VisualEditor.
	 */
	function imReplaceHachek( self, e, isVE ) {
		// Extended Latin to Basic
		var from = {
			'č': 'c',
			'ě': 'e',
			'ĺ': 'l',
			'ń': 'n',
			'š': 's',
			'ž': 'z'
		}
		// Basic Latin to Extended
		var to = {
			'c': 'č',
			'e': 'ě',
			'l': 'ĺ',
			'n': 'ń',
			's': 'š',
			'z': 'ž'
		}
	
		// VisualEditor
		if ( isVE ) {
			for ( var key in to ) {
				ve_replaceLetter( key, to[key], 'x', 2 );
			}
			return;
		};
	
		// WikiEditor
		replaceMultipleLetters( self, e, from, to, 'x' );
	}
	
	/**
	 * Print Є on Serbian/Macedonian keyboard.
	 * 
	 * @param self HTML element.
	 * @param e Event information.
	 * @param isVE Whether function is called from VisualEditor.
	 */
	function imReplaceDzheToE( self, e, isVE ) {
		// Extended Cyrillic to Basic
		var from = {
			'є': 'е'
		}
		// Basic Cyrillic to Extended
		var to = {
			'е': 'є'
		}
	
		// VisualEditor
		if ( isVE ) {
			for ( var key in to ) {
				ve_replaceLetter( key, to[key], 'џ', 2 );
			}
			return;
		};
	
		// WikiEditor
		replaceMultipleLetters( self, e, from, to, 'џ' );
	}
	
	/**
	 * Print Ы on Ukrainian keyboard and И otherwise.
	 * 
	 * @param self HTML element.
	 * @param e Event information.
	 * @param isVE Whether function is called from VisualEditor.
	 */
	function imReplaceCyrlI( self, e, isVE ) {
		// Replacements
		var to = {
			'І': 'И',
			'і': 'и'
		}
	
		// VisualEditor
		if ( isVE ) {
			// І to И
			for ( var key in to ) {
				ve_replace( key, to[key], key.length );
			}
	
			// Ь І to Ы
			ve_replaceLetter( 'ь', 'ы', 'і', 2 );
			return;
		};
		
		// Has to be written before any changes
		var lastEntry = self.lastEntry( 1 ).toLowerCase();
	
		// WikiEditor: Ь І to Ы
		replaceMultipleLetters( self, e, { 'ы': 'ь' }, { 'ь': 'ы' }, 'і' );
	
		// WikiEditor: І to И
		var lastEntry = self.lastEntry( 1 ).toLowerCase();
		if ( [ 'ь', 'і', 'ы' ].indexOf( lastEntry ) > -1 ) {
			return;
		}
	
		if ( to[ e.key ] ) {
			replaceLetter( self, e, e.key, to[ e.key ] );
			return;
		}
		console.log('ext.gadget.im: key ' + e.key + ' has not returned a function.');
	}
	
	/**
	 * Print soft letters instead of ь.
	 * 
	 * @param self HTML element.
	 * @param e Event information.
	 * @param isVE Whether function is called from VisualEditor.
	 */
	function imReplaceSoftSign( self, e, isVE ) {
		// Extended Cyrillic to Basic
		var from = {
			'є': 'е',
			'љ': 'л',
			'њ': 'н'
		}
		// Basic Cyrillic to Extended
		var to = {
			'е': 'є',
			'л': 'љ',
			'н': 'њ'
		}
	
		// VisualEditor
		if ( isVE ) {
			for ( var key in to ) {
				ve_replaceLetter( key, to[ key ], 'ь', 2 );
			}
			return;
		};
	
		// WikiEditor
		replaceMultipleLetters( self, e, from, to, 'ь' );
	}
	
	/**
	 * Replace unused letters with appropriate ones.
	 * 
	 * @param self HTML element.
	 * @param e Event information.
	 * @param isVE Whether function is called from VisualEditor.
	 */
	function imReplaceUnused( self, e, isVE ) {
		// Replacements
		var to = {
			'Й': 'Ј',
			'й': 'ј',
			'Э': 'Е',
			'э': 'е'
		}
	
		// VisualEditor
		if ( isVE ) {
			for ( var key in to ) {
				ve_replace( key, to[ key ], key.length );
			}
			return;
		};
	
		if ( to[ e.key ] ) {
			replaceLetter( self, e, e.key, to[ e.key ] );
			return;
		}
		console.log('ext.gadget.im: key ' + e.key + ' has not returned a function.');
	}
	
	/*
	 * Key combos and Visual Editor functions
	 */
	var keyCombos = {};
	
	keyCombos['Backspace'] = imShiftBackspace;
	
	keyCombos['X'] = imReplaceHachek;
	keyCombos['x'] = imReplaceHachek;
	imVeFuncs['x'] = imReplaceHachek;
	
	keyCombos['І'] = imReplaceCyrlI;
	keyCombos['і'] = imReplaceCyrlI;
	imVeFuncs['і'] = imReplaceCyrlI;
	
	keyCombos['Й'] = imReplaceUnused;
	keyCombos['й'] = imReplaceUnused;
	imVeFuncs['й'] = imReplaceUnused;
	keyCombos['Э'] = imReplaceUnused;
	keyCombos['э'] = imReplaceUnused;
	imVeFuncs['э'] = imReplaceUnused;
	
	keyCombos['Џ'] = imReplaceDzheToE;
	keyCombos['џ'] = imReplaceDzheToE;
	imVeFuncs['џ'] = imReplaceDzheToE;
	keyCombos['Ь'] = imReplaceSoftSign;
	keyCombos['ь'] = imReplaceSoftSign;
	imVeFuncs['ь'] = imReplaceSoftSign;
	
	/**
	 * Return some text before the caret.
	 * 
	 * @param target HTML element.
	 * @param length Number of characters.
	 * @returns String before the caret.
	 */
	function lastEntry( target, length ) {
		var currSelection = $( target ).textSelection( 'getCaretPosition', { startAndEnd: true } );
		var selStart = currSelection[ 0 ];
		var selEnd = currSelection[ 1 ];
	
		// Calculate caret position differently if there is a selection
		var caretPos = ( selStart === selEnd ? selStart : selEnd );
		var start = caretPos - length;
	
		return $( target ).textSelection( 'getContents' )
			.substring( start > 0 ? start : 0, caretPos );
	}

	/**
	 * Select some characters and insert text in their position.
	 * 
	 * @param target HTML element.
	 * @param text Inserted text.
	 * @param strip Number of replaced characters.
	 */
	function insert( target, text, strip ) {
		var currSelection = $( target ).textSelection( 'getCaretPosition', { startAndEnd: true } );
		var selStart = currSelection[ 0 ];
		var selEnd = currSelection[ 1 ];

		// Do not touch existing selection
		if ( selStart === selEnd ) {
			$( target ).textSelection( 'setSelection', {
				start: selStart - strip,
				end: selStart
			} );
		}

		// Keep undo working if possible
		if ( !document.execCommand( 'insertText', false, text ) ) {
			$( target ).textSelection( 'replaceSelection', text );

			var newPos = $( target ).textSelection( 'getCaretPosition', { startAndEnd: true } );
			$( target ).textSelection( 'setSelection', {
				start: newPos[ 1 ]
			} );
		}
	}

	/**
	 * Replace a single letter.
	 * 
	 * @param self HTML element.
	 * @param e Event information.
	 * @param from Object with replaced text.
	 * @param to Object with text after replacement.
	 */
	function replaceLetter( self, e, from, to ) {
		if ( e.ctrlKey || e.metaKey ) return;
		var length = ( from.length > 1 ? Math.abs( 1 - from.length ) : 0 );
		var lastEntry = self.lastEntry( length );
		var fromSubstring = from.substring( 0, from.length - 1 );
		if ( length > 0 && lastEntry.toLowerCase() !== fromSubstring.toLowerCase() ) {
			return;
		}
		
		var result = to.charAt( 0 ) + to.slice( 1 );
		if ( ( from.length > 1 && lastEntry.toUpperCase() === lastEntry )
			|| from.toUpperCase() === from ) {
			result = to.charAt( 0 ).toUpperCase() + to.slice(1);
		}
	
		self.insert( result, length );
	
		e.preventDefault();
	}

	/**
	 * Replace multiple different letters with other ones. 
	 * 
	 * @param self HTML element
	 * @param e Event information
	 * @param from Object with replaced text
	 * @param to Object with text after replacement
	 * @param symbol Symbol that is getting replaced
	 */
	function replaceMultipleLetters( self, e, from, to, symbol ) {
		if ( e.ctrlKey || e.metaKey ) return;
		var obj = null;
	
		var lastEntry = self.lastEntry( 1 );
		var isUpperCase = ( lastEntry.toUpperCase() === lastEntry );
		if ( isUpperCase ) {
			lastEntry = lastEntry.toLowerCase();
		}
	
		var trail = '';
		if ( Object.keys( to ).indexOf( lastEntry ) > -1 ) {
			obj = to;
		} else if ( Object.keys( from ).indexOf( lastEntry ) > -1 ) {
			obj = from;
			trail = symbol;
		}
	
		if ( obj !== null ) {
			var result = ( isUpperCase ? obj[ lastEntry ].toUpperCase() : obj[ lastEntry ] ) + trail;
			self.insert( result, 1 );
	
			e.preventDefault();
		}
	}
	
	/**
	 * Register/unregister commands in Visual Editor.
	 * 
	 * @param from Object with replaced text.
	 * @param to Object with text after replacement.
	 * @param strip Number of stripped characters.
	 */
	function ve_replace( from, to, strip ) {
		if ( !( 've' in window ) || !ve.ui ) return;

		// Random name with prefix
		var name = 'im-' + veCommandCount;
		
		// Disable the command if required
		if ( !imEnabled ) {
			var doneAction = false;
			if ( ve.ui.sequenceRegistry ) {
				ve.ui.sequenceRegistry.unregister( name );
				doneAction = true;
			}
			if ( ve.ui.wikitextSequenceRegistry ) {
				ve.ui.wikitextSequenceRegistry.unregister( name );
				doneAction = true;
			}
	
			if ( doneAction ) veCommandCount++;
			return;
		};

		if ( ve.ui.commandRegistry ) {
			ve.ui.commandRegistry.register(
				// 1st true: annotate, 2nd true: collapse to end
				new ve.ui.Command(name, 'content', 'insert', { args: [ to, true, true ] } )
			);
		}
	
		// Create and register a sequence
		var seq = new ve.ui.Sequence( name, name, from, strip );
		if ( ve.ui.sequenceRegistry ) {
			ve.ui.sequenceRegistry.register( seq );
		}
		if ( ve.ui.wikitextSequenceRegistry ) {
			ve.ui.wikitextSequenceRegistry.register( seq );
		}
	
		veCommandCount++;
	}
	
	/**
	 * Replace a letter in Visual Editor back and forth.
	 * 
	 * @param from Object with replaced text.
	 * @param to Object with text after replacement.
	 * @param symbol Replaced symbol.
	 * @param strip Number of stripped characters.
	 */
	function ve_replaceLetter( from, to, symbol, strip ) {
		var reSymbol = ( symbol ? '[' + symbol.toUpperCase() + symbol + ']$' : '$' );
		symbol = ( symbol ? symbol : '' );
		var ucCombo = new RegExp( from.toUpperCase() + reSymbol );
		var ucComboRevert = new RegExp( to.toUpperCase() + reSymbol );
	
		// dj to ď
		ve_replace( from + symbol, to, strip );
		ve_replace( ucCombo, to.toUpperCase(), strip );
		// ďj to dj
		ve_replace( to + symbol, from + symbol, strip );
		ve_replace( ucComboRevert, from.toUpperCase() + symbol, strip );
	}
	
	/**
	 * Autocorrect the input from any text field.
	 * 
	 * @param e Event information.
	 */
	function autocorrect( e ) {
		if ( !imEnabled ) return true;
		if ( !e.key ) return true;
	
		// If there is no function for this key, stop future actions
		if ( Object.keys( keyCombos ).indexOf( e.key ) === -1 ) {
			return true;
		}
	
		// Add CodeMirror support hack here
		var element = this;
		if ( element.classList.contains( 'CodeMirror-code' ) && $( mainTextbox ).length ) {
			element = mainTextbox;
		}
	
		var currSelection = $( element ).textSelection( 'getCaretPosition', { startAndEnd: true } );
		var selStart = currSelection[ 0 ];
		var selEnd = currSelection[ 1 ];
	
		keyCombos[ e.key ]( element, e, false, selStart, selEnd );
	}

	/**
	 * Toggle VisualEditor autocorrect.
	 * 
	 * @param status Current input method status.
	 */
	function ve_autocorrect( status ) {
		if ( !status ) status = imEnabled;
		if ( status === false && veCommandCount === 0 ) return;

		veCommandCount = 0;
		for ( var func in imVeFuncs ) {
			imVeFuncs[ func ]( null, null, true );
		}
	}

	/**
	 * Render the menu.
	 */
	function renderMenu() {
		if ( $( '#p-im' ).length ) return;
		
		// Custom setup on Minerva
		if ( mw.config.get( 'skin' ) === 'minerva' ) {
			var $portlet = mw.util.addPortletLink(
				'pt-preferences',
				imMenu.helpLink,
				imMenu.title
			);
			var $portletLink = $( $portlet ).find( 'a' );
			
			getStatus().then( function( status ) {
				imEnabled = status;
				$portletLink.find( 'span:not(.mw-ui-icon)' )
					.text( imMenu.title + ': ' + ( imEnabled ? imMenu.disable : imMenu.enable ) );
			} );
			
			$portletLink.on( 'click', function( e ) {
				e.preventDefault();
				
				setStatus();
				$portletLink.find( 'span:not(.mw-ui-icon)' )
					.text( imMenu.title + ': ' + ( imEnabled ? imMenu.disable : imMenu.enable ) );
			} );
			return;
		}
		
		// Default setup
		var $panel = $( '#p-tb' ).clone().attr( 'id', 'p-im' ).attr( 'aria-labelledby', 'p-im-label' );
		$panel.find( '#p-tb-label' ).attr( 'id', 'p-im-label' ).text( imMenu.title );
		var $body = $panel.find( ':last-child' ).eq( 0 );
		$body.empty();
	
		var $toggle = $( '<label>' ).attr( 'for', 'im-toggle' ).text( ' ' + imMenu.enable + ' [Ctrl+M]' );
		var $checkbox = $( '<input>' )
			.attr( 'type', 'checkbox' )
			.attr( 'id', 'im-toggle' )
			.prependTo( $toggle );
		$checkbox.change( function () {
			setStatus( this.checked );
	
			if ( $( mainTextbox ).length ) {
				$( mainTextbox ).focus();
			}
		} );
	
		getStatus().then( function( status ) {
			$checkbox.attr( 'checked', imEnabled = status );
		} );
	
		var $help = $( '<a>' ).attr( 'href', imMenu.helpLink ).text( imMenu.help );
		$body
			.append( $( '<p>' ).html( $toggle ) )
			.append( $( '<p>' ).text( imMenu.description ) )
			.append( $( '<p>' ).html( $help ) );
	
		$( '#p-tb' ).after( $panel );	
	}

	/**
	 * Read user option / local storage for gadget status.
	 * 
	 * @return {string} Value from option / local storage or true.
	 */
	function getStatus() {
		var value;
		
		return mw.loader.using( 'mediawiki.storage' ).then( function() {
			value = mw.storage.get( 'ext-gadget-im' );

			return value !== null ? false : true;
		} );
	}
	
	/**
	 * Set user option / cookie for gadget status.
	 * 
	 * @param target HTML element.
	 */
	function setStatus( status ) {
		if ( !status ) status = !imEnabled;
		var isDefault = status === true;

		mw.loader.using( 'mediawiki.storage' ).done( function() {
			var action = ( isDefault ? 'remove' : 'set' );

			var stored = mw.storage[ action ]( 'ext-gadget-im', status );
			if ( stored ) {
				var $toggle = $( '#im-toggle' );
				$toggle.prop( 'checked', status );
				imEnabled = status;
				
				ve_autocorrect( status );
			}
		} );
	}

	/**
	 * Add input method events to an element.
	 * 
	 * @param target HTML element.
	 */
	function addEvents( target ) {
		if ( !target || target[ defaultStorageKey ] === true ) return;
		if (
			target.matches( ignoredFields.join() )
			|| ( target.tagName !== 'TEXTAREA' && target.tagName !== 'INPUT' && !target.isContentEditable )
			|| target.type === 'submit'
		) return;

		target.onkeydown = autocorrect;
		
		target.lastEntry = function( length ) {
			return lastEntry( this, length );
		}
		target.insert = function( text, strip ) {
			return insert( this, text, strip );
		}

		target[ defaultStorageKey ] = true;
	}

	/**
	 * Toggle the script from keyboard on Ctrl+M
	 * 
	 * @param e Event information.
	 */
	 function toggleOnCtrlM( e ) {
		if ( !e.ctrlKey ) return true;
		if ( e.altKey || e.shiftKey ) return true;
		if ( e.which !== 77 && e.which !== 109 ) return true;

		e.preventDefault();
		setStatus();
		return false;
	}

	/**
	 * Start the script.
	 */
	function init() {
		// Add events to all existing fields
		var fields = document.querySelectorAll( 'input, textarea' );
		for ( var i = 0; i < fields.length; i++ ) {
			var el = fields[ i ];
			addEvents( el );

			if ( !mainTextbox && mainFormFields.indexOf( '#' + el.id ) > -1 ) {
				mainTextbox = el;
			}
		}
		renderMenu();

		document.addEventListener( 'keydown', toggleOnCtrlM );

		// Add Visual Editor support
		if ( mw.config.get( 'wgIsProbablyEditable' ) ) {
			var veDeps = [ 'ext.visualEditor.desktopArticleTarget.init' ];
			mw.loader.using( veDeps, function () {
				mw.libs.ve.addPlugin( ve_autocorrect );
			} );
	
			// Add DiscussionTools support
			if ( mw.config.get( 'wgDiscussionToolsFeaturesEnabled' ) ) {
				mw.loader.using( [ 'ext.discussionTools.ReplyWidgetVisual' ] ).then( function() {
					mw.loader.using( veDeps, ve_autocorrect );
				} );
			}
	
			// Add veaction (edge cases in VE) / Flow support
			var isFlowBoard = mw.config.get( 'wgPageContentModel' ) === 'flow-board';
			if ( isFlowBoard || location.href.search( 'veaction' ) > 0 ) {
				veDeps.push( 'ext.visualEditor.core' );
				if ( isFlowBoard || mw.libs && mw.libs.ve && mw.libs.ve.isWikitextAvailable ) {
					veDeps.push( 'ext.visualEditor.mwwikitext' );
				}
				
				mw.loader.using( veDeps, ve_autocorrect );
			}
		}

		// Watch all input on the page
		document.addEventListener( 'keydown', function( e ) {
			addEvents( e.target );
		} );
	}

	mw.loader.using( 'jquery.textSelection', init );
}() );