( 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 ) );