jquery.ime.js 19.7 KB
Newer Older
priyank's avatar
priyank committed
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765
( function ( $ ) {
	'use strict';

	// rangy is defined in the rangy library
	/*global rangy */

	/**
	 * IME Class
	 * @param {Function} [options.helpHandler] Called for each input method row in the selector
	 * @param {Object} options.helpHandler.imeSelector
	 * @param {String} options.helpHandler.ime Id of the input method
	 */
	function IME( element, options ) {
		this.$element = $( element );
		// This needs to be delayed here since extending language list happens at DOM ready
		$.ime.defaults.languages = arrayKeys( $.ime.languages );
		this.options = $.extend( {}, $.ime.defaults, options );
		this.active = false;
		this.shifted = false;
		this.inputmethod = null;
		this.language = null;
		this.context = '';
		this.selector = this.$element.imeselector( this.options );
		this.listen();
	}

	IME.prototype = {
		constructor: IME,

		/**
		 * Listen for events and bind to handlers
		 */
		listen: function () {
			this.$element.on( 'keypress.ime', $.proxy( this.keypress, this ) );
			this.$element.on( 'keyup.ime', $.proxy( this.keyup, this ) );
			this.$element.on( 'keydown.ime', $.proxy( this.keydown, this ) );
			this.$element.on( 'destroy.ime', $.proxy( this.destroy, this ) );
			this.$element.on( 'enable.ime', $.proxy( this.enable, this ) );
			this.$element.on( 'disable.ime', $.proxy( this.disable, this ) );
		},

		/**
		 * Transliterate a given string input based on context and input method definition.
		 * If there are no matching rules defined, returns the original string.
		 *
		 * @param {string} input
		 * @param {string} context
		 * @param {boolean} altGr whether altGr key is pressed or not
		 * @returns {object} transliteration object
		 * @returns {bool} return.noop Whether to consider input processed or passed through.
		 * @returns {string} return.output the transliterated input or input unmodified.
		 */
		transliterate: function ( input, context, altGr ) {
			var patterns, regex, rule, replacement, i, retval;

			if ( altGr ) {
				patterns = this.inputmethod.patterns_x || [];
			} else {
				patterns = this.inputmethod.patterns || [];
			}

			if ( this.shifted ) {
				// if shift is pressed give priority for the patterns_shift
				// if exists.
				// Example: Shift+space where shift does not alter the keycode
				patterns = ( this.inputmethod.patterns_shift || [] )
					.concat( patterns );
			}

			if ( $.isFunction( patterns ) ) {
				// For backwards compatibility, allow the rule functions to return plain
				// string. Determine noop by checking whether input is different from
				// output. If the rule function returns object, just return it as-is.
				retval = patterns.call( this, input, context );
				if ( typeof retval === 'string' ) {
					return { noop: input === retval, output: retval };
				}

				return retval;
			}

			for ( i = 0; i < patterns.length; i++ ) {
				rule = patterns[i];
				regex = new RegExp( rule[0] + '$' );

				// Last item in the rules.
				// It can also be a function, because the replace
				// method can have a function as the second argument.
				replacement = rule.slice( -1 )[0];

				// Input string match test
				if ( regex.test( input ) ) {
					// Context test required?
					if ( rule.length === 3 ) {
						if ( new RegExp( rule[1] + '$' ).test( context ) ) {
							return { noop: false, output: input.replace( regex, replacement ) };
						}
					} else {
						return { noop: false, output: input.replace( regex, replacement ) };
					}
				}
			}

			// No matches, return the input
			return { noop: true, output: input };
		},

		keyup: function ( e ) {
			if ( e.which === 16 ) { // shift key
				this.shifted = false;
			}
		},

		keydown: function ( e ) {
			if ( e.which === 16 ) { // shift key
				this.shifted = true;
			}
		},

		/**
		 * Keypress handler
		 * @param {jQuery.Event} e Event
		 * @returns {Boolean}
		 */
		keypress: function ( e ) {
			var altGr = false,
				c, startPos, pos, endPos, divergingPos, input, replacement;

			if ( !this.active ) {
				return true;
			}

			if ( !this.inputmethod ) {
				return true;
			}

			// handle backspace
			if ( e.which === 8 ) {
				// Blank the context
				this.context = '';
				return true;
			}

			if ( e.altKey || e.altGraphKey ) {
				altGr = true;
			}

			// Don't process ASCII control characters except linefeed,
			// as well as anything involving Ctrl, Meta and Alt,
			// but do process extended keymaps
			if ( ( e.which < 32 && e.which !== 13 && !altGr ) || e.ctrlKey || e.metaKey ) {
				// Blank the context
				this.context = '';

				return true;
			}

			c = String.fromCharCode( e.which );

			// Get the current caret position. The user may have selected text to overwrite,
			// so get both the start and end position of the selection. If there is no selection,
			// startPos and endPos will be equal.
			pos = this.getCaretPosition( this.$element );
			startPos = pos[0];
			endPos = pos[1];

			// Get the last few characters before the one the user just typed,
			// to provide context for the transliteration regexes.
			// We need to append c because it hasn't been added to $this.val() yet
			input = this.lastNChars(
				this.$element.val() || this.$element.text(),
				startPos,
				this.inputmethod.maxKeyLength
			);
			input += c;

			replacement = this.transliterate( input, this.context, altGr );

			// Update the context
			this.context += c;

			if ( this.context.length > this.inputmethod.contextLength ) {
				// The buffer is longer than needed, truncate it at the front
				this.context = this.context.substring(
					this.context.length - this.inputmethod.contextLength
				);
			}

			// Allow rules to explicitly define whether we match something.
			// Otherwise we cannot distinguish between no matching rule and
			// rule that provides identical output but consumes the event
			// to prevent normal behavior. See Udmurt layout which uses
			// altgr rules to allow typing the original character.
			if ( replacement.noop ) {
				return true;
			}

			// Drop a common prefix, if any
			divergingPos = this.firstDivergence( input, replacement.output );
			input = input.substring( divergingPos );
			replacement.output = replacement.output.substring( divergingPos );
			replaceText( this.$element, replacement.output, startPos - input.length + 1, endPos );

			e.stopPropagation();

			return false;
		},

		/**
		 * Check whether the input method is active or not
		 * @returns {Boolean}
		 */
		isActive: function () {
			return this.active;
		},

		/**
		 * Disable the input method
		 */
		disable: function () {
			this.active = false;
			$.ime.preferences.setIM( 'system' );
		},

		/**
		 * Enable the input method
		 */
		enable: function () {
			this.active = true;
		},

		/**
		 * Toggle the active state of input method
		 */
		toggle: function () {
			this.active = !this.active;
		},

		/**
		 * Destroy the binding of ime to the editable element
		 */
		destroy: function () {
			$( 'body' ).off( '.ime' );
			this.$element.off( '.ime' ).removeData( 'ime' ).removeData( 'imeselector' );
		},

		/**
		 * Get the current input method
		 * @returns {string} Current input method id
		 */
		getIM: function () {
			return this.inputmethod;
		},

		/**
		 * Set the current input method
		 * @param {string} inputmethodId
		 */
		setIM: function ( inputmethodId ) {
			this.inputmethod = $.ime.inputmethods[inputmethodId];
			$.ime.preferences.setIM( inputmethodId );
		},

		/**
		 * Set the current Language
		 * @param {string} languageCode
		 * @returns {Boolean}
		 */
		setLanguage: function ( languageCode ) {
			if ( !$.ime.languages[languageCode] ) {
				debug( 'Language ' + languageCode + ' is not known to jquery.ime.' );

				return false;
			}

			this.language = languageCode;
			$.ime.preferences.setLanguage( languageCode );
			return true;
		},

		/**
		 * Get current language
		 * @returns {string}
		 */
		getLanguage: function () {
			return this.language;
		},

		/**
		 * load an input method by given id
		 * @param {string} inputmethodId
		 * @return {jQuery.Promise}
		 */
		load: function ( inputmethodId ) {
			var ime = this,
				deferred = $.Deferred(),
				dependency;

			if ( $.ime.inputmethods[inputmethodId] ) {
				return deferred.resolve();
			}

			// Validate the input method id.
			if ( !$.ime.sources[inputmethodId] ) {
				return deferred.reject();
			}

			dependency = $.ime.sources[inputmethodId].depends;
			if ( dependency && !$.ime.inputmethods[dependency] ) {
				ime.load( dependency ).done( function () {
					ime.load( inputmethodId ).done( function () {
						deferred.resolve();
					} );
				} );

				return deferred;
			}

			debug( 'Loading ' + inputmethodId );
			deferred = $.ajax( {
				url: ime.options.imePath + $.ime.sources[inputmethodId].source,
				dataType: 'script',
				cache: true
			} ).done( function () {
				debug( inputmethodId + ' loaded' );
			} ).fail( function ( jqxhr, settings, exception ) {
				debug( 'Error in loading inputmethod ' + inputmethodId + ' Exception: ' + exception );
			} );

			return deferred.promise();
		},

		/**
		 * Returns an array [start, end] of the beginning
		 * and the end of the current selection in $element
		 * @returns {Array}
		 */
		getCaretPosition: function ( $element ) {
			return getCaretPosition( $element );
		},

		/**
		 * Set the caret position in the div.
		 * @param {jQuery} $element The content editable div element
		 * @param {Object} position An object with start and end properties.
		 * @return {Array} If the cursor could not be placed at given position, how
		 * many characters had to go back to place the cursor
		 */
		setCaretPosition: function ( $element, position ) {
			return setCaretPosition( $element, position );
		},

		/**
		 * Find the point at which a and b diverge, i.e. the first position
		 * at which they don't have matching characters.
		 *
		 * @param a String
		 * @param b String
		 * @return Position at which a and b diverge, or -1 if a === b
		 */
		firstDivergence: function ( a, b ) {
			return firstDivergence( a, b );
		},

		/**
		 * Get the n characters in str that immediately precede pos
		 * Example: lastNChars( 'foobarbaz', 5, 2 ) === 'ba'
		 *
		 * @param str String to search in
		 * @param pos Position in str
		 * @param n Number of characters to go back from pos
		 * @return Substring of str, at most n characters long, immediately preceding pos
		 */
		lastNChars: function ( str, pos, n ) {
			return lastNChars( str, pos, n );
		}
	};

	/**
	 * jQuery plugin ime
	 * @param {Object} option
	 */
	$.fn.ime = function ( option ) {
		return this.each( function () {
			var data,
				$this = $( this ),
				options = typeof option === 'object' && option;

			// Some exclusions: IME shouldn't be applied to textareas with
			// these properties.
			if ( $this.prop( 'readonly' ) ||
				$this.prop( 'disabled' ) ||
				$this.hasClass( 'noime' ) ) {
				return;
			}

			data = $this.data( 'ime' );

			if ( !data ) {
				data = new IME( this, options );
				$this.data( 'ime', data );
			}

			if ( typeof option === 'string' ) {
				data[option]();
			}
		} );
	};

	$.ime = {};
	$.ime.inputmethods = {};
	$.ime.sources = {};
	$.ime.preferences = {};
	$.ime.languages = {};

	var defaultInputMethod = {
		contextLength: 0,
		maxKeyLength: 1
	};

	$.ime.register = function ( inputMethod ) {
		$.ime.inputmethods[inputMethod.id] = $.extend( {}, defaultInputMethod, inputMethod );
	};

	// default options
	$.ime.defaults = {
		imePath: '../', // Relative/Absolute path for the rules folder of jquery.ime
		languages: [], // Languages to be used- by default all languages
		helpHandler: null // Called for each ime option in the menu
	};

	/**
	 * private function for debugging
	 */
	function debug( $obj ) {
		if ( window.console && window.console.log ) {
			window.console.log( $obj );
		}
	}

	/**
	 * Returns an array [start, end] of the beginning
	 * and the end of the current selection in $element
	 */
	function getCaretPosition( $element ) {
		var el = $element.get( 0 ),
			start = 0,
			end = 0,
			normalizedValue,
			range,
			textInputRange,
			len,
			newLines,
			endRange;

		if ( $element.is( '[contenteditable]' ) ) {
			return getDivCaretPosition( el );
		}

		if ( typeof el.selectionStart === 'number' && typeof el.selectionEnd === 'number' ) {
			start = el.selectionStart;
			end = el.selectionEnd;
		} else {
			// IE
			range = document.selection.createRange();

			if ( range && range.parentElement() === el ) {
				len = el.value.length;
				normalizedValue = el.value.replace( /\r\n/g, '\n' );
				newLines = normalizedValue.match( /\n/g );

				// Create a working TextRange that lives only in the input
				textInputRange = el.createTextRange();
				textInputRange.moveToBookmark( range.getBookmark() );

				// Check if the start and end of the selection are at the very end
				// of the input, since moveStart/moveEnd doesn't return what we want
				// in those cases
				endRange = el.createTextRange();
				endRange.collapse( false );

				if ( textInputRange.compareEndPoints( 'StartToEnd', endRange ) > -1 ) {
					if ( newLines ) {
						start = end = len - newLines.length;
					} else {
						start = end = len;
					}
				} else {
					start = -textInputRange.moveStart( 'character', -len );

					if ( textInputRange.compareEndPoints( 'EndToEnd', endRange ) > -1 ) {
						end = len;
					} else {
						end = -textInputRange.moveEnd( 'character', -len );
					}
				}
			}
		}

		return [start, end];
	}

	/**
	 * Helper function to get an IE TextRange object for an element
	 */
	function rangeForElementIE( element ) {
		var selection;

		if ( element.nodeName.toLowerCase() === 'input' ) {
			selection = element.createTextRange();
		} else {
			selection = document.body.createTextRange();
			selection.moveToElementText( element );
		}

		return selection;
	}

	function replaceText( $element, replacement, start, end ) {
		var selection,
			length,
			newLines,
			scrollTop,
			range,
			correction,
			textNode,
			element = $element.get( 0 );

		if ( $element.is( '[contenteditable]' ) ) {
			correction = setCaretPosition( $element, {
				start: start,
				end: end
			} );

			rangy.init();
			selection = rangy.getSelection();
			range = selection.getRangeAt( 0 );

			if ( correction[0] > 0 ) {
				replacement = selection.toString().substring( 0, correction[0] ) + replacement;
			}

			textNode = document.createTextNode( replacement );
			range.deleteContents();
			range.insertNode( textNode );
			range.commonAncestorContainer.normalize();
			start = end = start + replacement.length - correction[0];
			setCaretPosition( $element, {
				start: start,
				end: end
			} );

			return;
		}

		if ( typeof element.selectionStart === 'number' && typeof element.selectionEnd === 'number' ) {
			// IE9+ and all other browsers
			scrollTop = element.scrollTop;

			// Replace the whole text of the text area:
			// text before + replacement + text after.
			// This could be made better if range selection worked on browsers.
			// But for complex scripts, browsers place cursor in unexpected places
			// and it's not possible to fix cursor programmatically.
			// Ref Bug https://bugs.webkit.org/show_bug.cgi?id=66630
			element.value = element.value.substring( 0, start ) +
				replacement +
				element.value.substring( end, element.value.length );

			// restore scroll
			element.scrollTop = scrollTop;
			// set selection
			element.selectionStart = element.selectionEnd = start + replacement.length;
		} else {
			// IE8 and lower
			selection = rangeForElementIE(element);
			length = element.value.length;
			// IE doesn't count \n when computing the offset, so we won't either
			newLines = element.value.match( /\n/g );

			if ( newLines ) {
				length = length - newLines.length;
			}

			selection.moveStart( 'character', start );
			selection.moveEnd( 'character', end - length );

			selection.text = replacement;
			selection.collapse( false );
			selection.select();
		}
	}

	function getDivCaretPosition( element ) {
		var charIndex = 0,
			start = 0,
			end = 0,
			foundStart = false,
			foundEnd = false,
			sel;

		rangy.init();
		sel = rangy.getSelection();

		function traverseTextNodes( node, range ) {
			var i, childNodesCount;

			if ( node.nodeType === Node.TEXT_NODE ) {
				if ( !foundStart && node === range.startContainer ) {
					start = charIndex + range.startOffset;
					foundStart = true;
				}

				if ( foundStart && node === range.endContainer ) {
					end = charIndex + range.endOffset;
					foundEnd = true;
				}

				charIndex += node.length;
			} else {
				childNodesCount = node.childNodes.length;

				for ( i = 0; i < childNodesCount; ++i ) {
					traverseTextNodes( node.childNodes[i], range );
					if ( foundEnd ) {
						break;
					}
				}
			}
		}

		if ( sel.rangeCount ) {
			traverseTextNodes( element, sel.getRangeAt( 0 ) );
		}

		return [ start, end ];
	}

	function setCaretPosition( $element, position ) {
		var currentPosition,
			startCorrection = 0,
			endCorrection = 0,
			element = $element[0];

		setDivCaretPosition( element, position );
		currentPosition = getDivCaretPosition( element );
		// see Bug https://bugs.webkit.org/show_bug.cgi?id=66630
		while ( position.start !== currentPosition[0] ) {
			position.start -= 1; // go back one more position.
			if ( position.start < 0 ) {
				// never go beyond 0
				break;
			}
			setDivCaretPosition( element, position );
			currentPosition = getDivCaretPosition( element );
			startCorrection += 1;
		}

		while ( position.end !== currentPosition[1] ) {
			position.end += 1; // go forward one more position.
			setDivCaretPosition( element, position );
			currentPosition = getDivCaretPosition( element );
			endCorrection += 1;
			if ( endCorrection > 10 ) {
				// XXX avoid rare case of infinite loop here.
				break;
			}
		}

		return [startCorrection, endCorrection];
	}

	/**
	 * Set the caret position in the div.
	 * @param {Element} element The content editable div element
	 * @param position
	 */
	function setDivCaretPosition( element, position ) {
		var nextCharIndex,
			charIndex = 0,
			range = rangy.createRange(),
			foundStart = false,
			foundEnd = false;

		range.collapseToPoint( element, 0 );

		function traverseTextNodes( node ) {
			var i, len;

			if ( node.nodeType === 3 ) {
				nextCharIndex = charIndex + node.length;

				if ( !foundStart && position.start >= charIndex && position.start <= nextCharIndex ) {
					range.setStart( node, position.start - charIndex );
					foundStart = true;
				}

				if ( foundStart && position.end >= charIndex && position.end <= nextCharIndex ) {
					range.setEnd( node, position.end - charIndex );
					foundEnd = true;
				}

				charIndex = nextCharIndex;
			} else {
				for ( i = 0, len = node.childNodes.length; i < len; ++i ) {
					traverseTextNodes( node.childNodes[i] );
					if ( foundEnd ) {
						rangy.getSelection().setSingleRange( range );
						break;
					}
				}
			}
		}

		traverseTextNodes( element );

	}

	/**
	 * Find the point at which a and b diverge, i.e. the first position
	 * at which they don't have matching characters.
	 *
	 * @param a String
	 * @param b String
	 * @return Position at which a and b diverge, or -1 if a === b
	 */
	function firstDivergence( a, b ) {
		var minLength, i;

		minLength = a.length < b.length ? a.length : b.length;

		for ( i = 0; i < minLength; i++ ) {
			if ( a.charCodeAt( i ) !== b.charCodeAt( i ) ) {
				return i;
			}
		}

		return -1;
	}

	/**
	 * Get the n characters in str that immediately precede pos
	 * Example: lastNChars( 'foobarbaz', 5, 2 ) === 'ba'
	 *
	 * @param str String to search in
	 * @param pos Position in str
	 * @param n Number of characters to go back from pos
	 * @return Substring of str, at most n characters long, immediately preceding pos
	 */
	function lastNChars( str, pos, n ) {
		if ( n === 0 ) {
			return '';
		} else if ( pos <= n ) {
			return str.substr( 0, pos );
		} else {
			return str.substr( pos - n, n );
		}
	}

	function arrayKeys ( obj ) {
		return $.map( obj, function( element, index ) {
			return index;
		} );
	}
}( jQuery ) );