/* -*- Mode: js; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / /* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ /* global PAGE_INDEX_DEFAULT, InputMethods, KeyEvent, PromiseStorage */ 'use strict'; /* * This latin input method provides four forms of input assistance: * * 1) word suggestions * * 2) auto correction * * 3) auto capitalization * * 4) punctuation assistance by converting space space to period space * and by transposing space followed by punctuation. * * These input modifications are controlled by the type and inputmode * properties of the input element that has the focus. If inputmode is * "verbatim" then the input method does not modify the user's input in any * way. See getInputMode() for a description of input modes. * * TODO: * * when deciding whether to autocorrect, if the first 2 choices are * a prefix of one another, then consider the ratio of 1st to 3rd instead * of 1st to second possibly? If there different forms of the same word * and that word is the most likely, then substitute it? * * add a per-language settings-based list of customizable corrections? * * Display an X icon in the suggestions line to give the user a way * to dismiss an autocorrection? (Easier than space, backspace, space). * * Display a + icon in the suggestions line to give the user a way to * add the current input to a personal word list so it doesn't get * auto-corrected? * * Use color somehow to indicate that a word is properly spelled? * * Make the phone vibrate when it makes an automatic corection? */ (function() { // Register ourselves in the keyboard's set of input methods // The functions used here are all defined below InputMethods.latin = { init: init, activate: activate, deactivate: deactivate, displaysCandidates: displaysCandidates, click: click, select: select, dismissSuggestions: dismissSuggestions, setLayoutPage: setLayoutPage, setLayoutParams: setLayoutParams, setLanguage: setLanguage, stateChange: stateChange, generateNearbyKeyMap: findNearbyKeys }; // This is the object that is passed to init(). // We use the methods of this object to communicate with the keyboard. var keyboard; // If defined, this is a worker thread that produces word suggestions for us var worker; // PromiseStorage for access to indexedDB for user dictionary var dbStore; // These variables are the input method's state. Most of them are // passed to the activate() method or are derived in that method. var language; // The user's language var inputMode; // The inputmode we're using: see getInputMode() var capitalizing; // Are we auto-capitalizing for this activation? var suggesting; // Are we offering suggestions for this activation? var correcting; // Are we auto-correcting user input? var punctuating; // Are we offering punctuation assistance? var inputText; // The input text var cursor; // The insertion position var selection; // The end of the selection, if there is one, or 0 var lastSpaceTimestamp; // If the last key was a space, this is the timestamp var layoutParams; // Parameters passed to setLayoutParams var nearbyKeyMap; // Map keys to nearby keys var serializedNearbyKeyMap; // A stringified version of the above var idleTimer; // Used by deactivate var suggestionsTimer; // Used by updateSuggestions; var autoCorrection; // Correction to make if next input is space var revertTo; // Revert to this on backspace after autocorrect var revertFrom; // Revert away from this on backspace var disableOnRevert; // Do we disable auto correction when reverting? var correctionDisabled; // Temporarily diabled after reverting? var currentPage; // The current layout page var gotNormalKey; // Indicate if we got a normal key in an alt page // Terminate the worker when the keyboard is inactive for this long. var WORKER_TIMEOUT = 30000; // 30 seconds of idle time // If we get an autorepeating key is sent to us, don't offer suggestions // for this long, until we're pretty certain that the autorepeat // has stopped. var AUTOREPEAT_DELAY = 250; // Some keycodes that we use var SPACE = KeyEvent.DOM_VK_SPACE; var BACKSPACE = KeyEvent.DOM_VK_BACK_SPACE; var RETURN = KeyEvent.DOM_VK_RETURN; var PERIOD = 46; var QUESTION = 63; var EXCLAMATION = 33; var COMMA = 44; var COLON = 58; var SEMICOLON = 59; var ATPERSAND = 64; var DOUBLEQUOTE = 34; var CLOSEPAREN = 41; // all whitespace characters // U+FFFC place holder is added to white space // this enables suggestions // when cursor is before place holder. var WS = /^[\s\ufffc]+$/; // word separator characters // U+FFFC is the placeholder character for non-text object var WORDSEP = /^[\s.,?!;:\ufffc]+$/; var DOUBLE_SPACE_TIME = 700; // ms between spaces to convert to ". " // Don't offer to autocorrect unless we're reasonably certain that the // user wants this correction. The first suggested word must be at least // this much more highly weighted than the second suggested word. var AUTO_CORRECT_THRESHOLD = 1.30; // Append a suffix when a suggestion was picked. In general, we // use space in Latin script as word divider. var SUGGESTION_SUFFIX = ' '; // If the length doesn't match between input and first suggestion. // The min. frequency the first suggestion needs to have if we turn it into // an autocorrect action var MIN_LENGTH_MISMATCH_THRESHOLD = 5; var USER_DICT_DB_NAME = 'UserDictLatin'; // Predictions from user dictionary need to have weight larger than this in // order to take precedence over built-in dictionary predictions var USER_DICT_PREDICTION_BUMP_THRESHOLD = 1; // If this promise exists, that means we are // currently loading the new dictionary. var getDictionaryDataPromise = null; // The promise for getting user dictionary from PromiseStorage var getUserDictionaryBlobPromise = null; // keyboard.js calls this to pass us the interface object we need // to communicate with it function init(interfaceObject) { keyboard = interfaceObject; dbStore = new PromiseStorage(USER_DICT_DB_NAME); dbStore.start(); } // Given the type property and inputmode attribute of a form element, // this function returns the inputmode that this IM should use. The // return value will be one of these strings: // // 'verbatim': don't alter the user's input at all // 'latin': offer word suggestions/corrections, but no capitalization // 'latin-prose': offer word suggestions and capitalization // function getInputMode(type, mode) { // For text, textarea and search types, use the requested inputmode // if it is valid and supported except numeric/digit mode. For // numeric/digit mode, we return verbatim since no typing assitance // is required. Otherwise default to latin for text and search and to // latin-prose for textarea. For all other form fields, use verbatim mode // so we never alter input. switch (type) { case 'text': case 'textarea': case 'search': switch (mode) { case 'verbatim': case 'latin': case 'latin-prose': return mode; case 'numeric': case 'digit': return 'verbatim'; default: return (type === 'textarea') ? 'latin-prose' : 'latin'; } break; default: return 'verbatim'; } } // This gets called whenever the keyboard pops up to tell us everything // we need to provide useful typing assistance. It also gets called whenever // the user taps on an input field to move the cursor. That means that there // may be multiple calls to activate() without calls to deactivate between // them. function activate(lang, state, options) { inputMode = getInputMode(state.type, state.inputmode); inputText = state.text; cursor = state.selectionStart; if (state.selectionEnd > state.selectionStart) { selection = state.selectionEnd; } else { selection = 0; } // Figure out what kind of input assistance we're providing for this // activation. capitalizing = punctuating = (inputMode === 'latin-prose'); suggesting = (options.suggest && inputMode !== 'verbatim'); correcting = (options.correct && inputMode !== 'verbatim'); // Some layouts (like French) need to disable punctuation autocorrection // all the time. if (options.correctPunctuation === false) { punctuating = false; } // Reset our state lastSpaceTimestamp = 0; autoCorrection = null; revertTo = revertFrom = ''; disableOnRevert = false; correctionDisabled = false; currentPage = PAGE_INDEX_DEFAULT; gotNormalKey = false; // The keyboard isn't idle anymore, so clear the timer if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } // Start off with the correct capitalization updateCapitalization(); // Get user dictionary blob. getUserDictionaryBlobPromise = dbStore.getItem('dictblob').then(function(blob) { // If worker is there, tell it we have a (new) user dictionary blob, // since setLanguage() may be bypassed due to language not changing. // (User dictionary is langauge-neutral.) // Do pass the argument if blob is undefined (no user dictionary words), // Since the worker needs to know if user has deleted all words. // Worker will bypass UserDictionary prediction as needed. // |undefined| can't be Transferred so we'll need to check for it. if (worker) { worker.postMessage({ cmd: 'setUserDictionary', args: [blob] }, typeof blob === 'undefined' ? undefined : [blob]); } return blob; })['catch'](function(e) { e && console.error('latin.js', e); }); // If we are going to offer suggestions, ensure that there is a worker // thread created and that it knows what language we're using, and then // start things off by requesting a first batch of suggestions. if (suggesting || correcting) { if (!worker || lang !== language) { setLanguage(lang); // This calls updateSuggestions } else { updateSuggestions(); } } } function deactivate() { if (!worker || idleTimer) { return; } idleTimer = setTimeout(terminateWorker, WORKER_TIMEOUT); } function terminateWorker() { if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } if (worker) { worker.terminate(); worker = null; keyboard.sendCandidates([]); // Clear any displayed suggestions autoCorrection = null; // and forget any pending correction. } } function setLanguage(newlang) { // If there is no worker and no language, or if there is a worker and // the language has not changed, then there is nothing to do here. if ((!worker && !newlang) || (worker && newlang === language)) { return; } language = newlang; // If there is a worker, and no new language, then kill the worker if (worker && !newlang) { terminateWorker(); return; } // Built-in dictionary is a hard requirement, i.e. if it is undefined, we // reset ourselves. // User dictionary is not a hard requirement. We swallow any abnormal // situation there at activate(), to make sure Promise.all() doesn't fail // on its rejection. getDictionaryDataPromise = Promise.all([ keyboard.getData('dictionaries/' + newlang + '.dict'), getUserDictionaryBlobPromise ]); getDictionaryDataPromise .then(function(values) { getDictionaryDataPromise = null; var builtInDict = values[0]; var userDict = values[1]; if (!builtInDict) { console.error( 'latin.js: dictionary is specified but can\'t be loaded.'); language = undefined; terminateWorker(); return; } setLanguageSync(newlang, builtInDict, userDict); })['catch'](function(e) { // workaround gjslint error e && console.error('latin.js', e); getDictionaryDataPromise = null; language = undefined; terminateWorker(); })['catch'](function(e) { e && console.error(e); }); } function setLanguageSync(newlang, dictData, userDict) { // If we get here, then we have to create a worker and set its language // or change the language of an existing worker. if (!worker) { // If we haven't created the worker before, do it now worker = new Worker('js/imes/latin/worker.js'); if (layoutParams && nearbyKeyMap) { worker.postMessage({ cmd: 'setNearbyKeys', args: [nearbyKeyMap]}); } worker.onmessage = function(e) { switch (e.data.cmd) { case 'log': console.log(e.data.message); break; case 'error': console.error(e.data.message); // If the error was a result of our setLanguage call, then // kill the worker because it can't do anything without // a valid dictionary. if (e.data.message.startsWith('setLanguage')) { terminateWorker(); } break; case 'predictions': // The worker is suggesting words: ask the keyboard to display them handleSuggestions(e.data.input, e.data.suggestions); break; } }; } // Tell the worker what language we're using and also pass the dictionary // data. // |undefined| can't be Transferred so we'll need to check for it. worker.postMessage({ cmd: 'setLanguage', args: [language, dictData, userDict] }, typeof userDict === 'undefined' ? [dictData] : [dictData, userDict]); // And now that we've changed the language, ask for new suggestions updateSuggestions(); } function displaysCandidates() { // If we are suggesting for the language, // we should always display the condidate panel. return !!(suggesting && language); } /* * The keyboard calls this method to tell us about user input. * * What we do with the input depends on various things: * * - whether we are suggesting, correcting, punctuating and/or capitalizing: * these are controlled by settings, input mode and input type * * - whether there is a selected region in the input field * * - whether there is an auto-correction ready (when input is space * or punctuation). * * - whether the last action was an autocorrection (when input is backspace) * * - the cursor position (affects suggestions, capitalization, etc.) * * If there is a selection just handle simple insertions and deletions * with no extra behavior. (I think) * * If input is a space or punctuation: * * If there is a autocorrection ready, and we are correcting and * the cursor is at the end of a word, make the correction * * If the previous character is a space, fix the punctuation. Note that * this only works for a subset of the punctuation characters. * * Otherwise just insert it. * * If we're correcting and corrections are temporarily diabled, turn them * back on. * * If input is a backspace: * * If we just did an auto-correction, revert it and turn off corrections * until the next space or punctuation character. * * If we just inserted a suggested word that the user selected, revert * the insertion, but don't disable autocorrect. * * Should we undo punctuation corrections this way, too? * * Otherwise, just delete the character before the cursor * * For any other input character, just insert it. * * Reset the backspace reversion state * * Update the capitalization state, if we're capitalizing */ function click(keyCode, repeat) { // If the key is anything other than a backspace, forget about any // previous changes that we would otherwise revert. if (keyCode !== BACKSPACE) { revertTo = revertFrom = ''; disableOnRevert = false; } var handler; if (selection) { // If there is selected text, don't do anything fancy here. handler = handleKey(keyCode); } else { switch (keyCode) { case SPACE: // This list of characters matches the WORDSEP regexp case RETURN: case PERIOD: case QUESTION: case EXCLAMATION: case COMMA: case COLON: case SEMICOLON: case DOUBLEQUOTE: case CLOSEPAREN: // These keys may trigger word or punctuation corrections handler = handleCorrections(keyCode); correctionDisabled = false; break; case BACKSPACE: handler = handleBackspace(repeat); break; default: handler = handleKey(keyCode); } } // After the next key is resolved, we could update the state here. return handler.then(function() { // handleCorrections() above or it is now out of date, so clear it // so it doesn't get used later autoCorrection = null; // And update the keyboard capitalization state, if necessary updateCapitalization(); // If we're offering suggestions, ask the worker to make them now updateSuggestions(repeat); // Exit symbol layout mode after space or return key is pressed. var isNonDefaultPage = currentPage !== PAGE_INDEX_DEFAULT; var isSpace = keyCode === SPACE; var isReturn = keyCode === RETURN; var isAtpersand = keyCode === ATPERSAND; var keyResetLayout = (isSpace && gotNormalKey) || isReturn || isAtpersand; if (isNonDefaultPage && keyResetLayout) { keyboard.setLayoutPage(PAGE_INDEX_DEFAULT); } // Next space key will change the layout if (keyCode !== SPACE) { gotNormalKey = true; } lastSpaceTimestamp = (keyCode === SPACE) ? Date.now() : 0; }, function() { // the previous sendKey or replaceSurroundingText has been rejected, // No need to update the state. })['catch'](function(e) { // ['catch'] for gjslint error // Print the error and make sure inputSequencePromise always resolves. console.error(e); }); } // Handle any key (including backspace) and do the right thing even if // there is a selection in the text field. This method does not perform // auto-correction or auto-punctuation. function handleKey(keycode, repeat) { // Generate the key event return keyboard.sendKey(keycode, repeat).then(function() { // First, update our internal state if (keycode === BACKSPACE) { if (selection) { // backspace while a region is selected erases the selection // and leaves the cursor at the selection start inputText = inputText.substring(0, cursor) + inputText.substring(selection); selection = 0; } else if (cursor > 0) { cursor--; inputText = inputText.substring(0, cursor) + inputText.substring(cursor + 1); // If we have temporarily disabled auto correction for the current // word and we've just backspaced over the entire word, then we can // re-enabled it again if (correctionDisabled && !wordBeforeCursor()) { correctionDisabled = false; } } } else { if (selection) { inputText = inputText.substring(0, cursor) + String.fromCharCode(keycode) + inputText.substring(selection); selection = 0; } else { inputText = inputText.substring(0, cursor) + String.fromCharCode(keycode) + inputText.substring(cursor); } cursor++; } }, function() { // sendKey got canceled, keep state the same }); } // Assuming that the word before the cursor is oldWord, send a // minimal number of key events to change it to newWord in the text // field. Also update our internal state to match the new textfield // content and cursor position. function replaceBeforeCursor(oldWord, newWord) { var oldWordLen = oldWord.length; var replPromise = keyboard.replaceSurroundingText(newWord, -oldWordLen, oldWordLen); return replPromise.then(function() { // Now update internal state inputText = inputText.substring(0, cursor - oldWordLen) + newWord + inputText.substring(cursor); cursor += newWord.length - oldWordLen; }); } // If we just did auto correction or auto punctuation, then backspace // should undo it. Otherwise it is just an ordinary backspace. function handleBackspace(repeat) { // If we made a correction and haven't changed it at all yet, // then revert it. var len = revertFrom ? revertFrom.length : 0; if (len && cursor >= len && inputText.substring(cursor - len, cursor) === revertFrom) { // Revert the content of the text field return replaceBeforeCursor(revertFrom, revertTo).then(function() { // If the change we just reverted was an auto-correction then // temporarily disable auto correction until the next space if (disableOnRevert) { correctionDisabled = true; } revertFrom = revertTo = ''; disableOnRevert = false; }); } else { return handleKey(BACKSPACE, repeat); } } // This function is called when the user types space, return or a punctuation // character. It performs auto correction or auto punctuation or just // inserts the character. function handleCorrections(keycode) { if (correcting && autoCorrection && !correctionDisabled && atWordEnd() && wordBeforeCursor() !== autoCorrection) { return autoCorrect(keycode); } else if (punctuating && cursor >= 2 && isWhiteSpace(inputText[cursor - 1]) && inputText[cursor - 1].charCodeAt(0) !== KeyEvent.DOM_VK_RETURN && !WORDSEP.test(inputText[cursor - 2])) { return autoPunctuate(keycode); } else { return handleKey(keycode); } } // Perform an autocorrection. Assumes that all pre-conditions for // auto-correction have been met. function autoCorrect(keycode) { // Get the word before the cursor var currentWord = wordBeforeCursor(); // Figure out the auto correction text var newWord = autoCorrection; // Make the correction return replaceBeforeCursor(currentWord, newWord).then(function() { // Remember the change we just made so we can revert it if the // user types backspace revertTo = currentWord; revertFrom = newWord; disableOnRevert = true; }).then(function() { // Send the keycode as separate key event because it may get canceled return handleKey(keycode).then(function() { revertFrom += String.fromCharCode(keycode); }); }); } // Auto punctuate, converting space punctuation to punctuation space // or converting space space to period space if the two spaces were // close enough together. Assumes that pre-conditions for auto punctuation // have been met. function autoPunctuate(keycode) { switch (keycode) { case SPACE: if (Date.now() - lastSpaceTimestamp < DOUBLE_SPACE_TIME) { return fixPunctuation(PERIOD, SPACE); } else { return handleKey(keycode); } break; case PERIOD: case QUESTION: case EXCLAMATION: case COMMA: return fixPunctuation(keycode); default: // colon and semicolon don't auto-punctuate because they're // used after spaces for smileys. return handleKey(keycode); } // In both the space space and the space period case we call this function // Second argument is the character reverting to if cancelling auto // punctuation // If the second argument is omitted, assume it is the same as the first function fixPunctuation(keycode, revertToKeycode) { return keyboard.sendKey(BACKSPACE) .then(function() { return keyboard.sendKey(keycode); }) .then(function() { return keyboard.sendKey(SPACE); }) .then(function() { var newtext = String.fromCharCode(keycode) + ' '; inputText = inputText.substring(0, cursor - 1) + newtext + inputText.substring(cursor); cursor++; // Remember this change so we can revert it on backspace revertTo = ' ' + String.fromCharCode(revertToKeycode || keycode); revertFrom = newtext; disableOnRevert = false; }); } } // When the worker thread sends us a batch of suggestions, deal // with them here. function handleSuggestions(input, suggestions) { // If we didn't get any suggestions just send the empty array to // clear any suggestions that are currently displayed. Do the same // if the word before the cursor has changed since we requested // these suggestions. That is, if the user has typed faster than we could // offer suggestions, ignore them. if (suggestions.length === 0 || wordBeforeCursor() !== input) { keyboard.sendCandidates([]); // Clear any displayed suggestions return; } // If input is ucase, then make all suggestions ucase as well. // Ignore input.length of 1, it never gets autocorrected anyway if (input.length > 1 && isUpperCase(input)) { for (var ix = 0; ix < suggestions.length; ix++) { suggestions[ix][0] = suggestions[ix][0].toUpperCase(); } } // We show no more than 3 suggestions; but we'd like to keep at least one // suggestion from user dictionary, if its weight is less than 1. However, // that suggestion can still be dropped if it matches the user input. // User dictionary suggestion is identified by suggestion[2] === true. var inputDefinedInUserDict = false; // See if the user's input is a valid word on the list of suggestions var inputIsSuggestion = false; var inputWeight = 0; var inputIndex; for (inputIndex = 0; inputIndex < suggestions.length; inputIndex++) { if (suggestions[inputIndex][0] === input) { inputIsSuggestion = true; inputDefinedInUserDict = !!suggestions[inputIndex][2]; inputWeight = suggestions[inputIndex][1]; break; } } // We never want to display the user's input as a suggestion so // remove it from the list if it is there. if (inputIsSuggestion) { suggestions.splice(inputIndex, 1); } // If we don't have any suggestions we're done if (suggestions.length === 0) { keyboard.sendCandidates([]); // Clear any displayed suggestions return; } // // If there was an exact match for the user input in the suggestions // it has already been removed. Now we need to loop through again // looking for approximate matches and bump those to the start of the // list. A word is an approximate match if all the letters match // when we ignore case, apostrophes and hyphens. The assumption here // is that the user is not expected to have to use the shift key or // the alternate forms menu or the punctuation menus. If they type all // the right letters, a matching word should have higher priority than // a non-matching word. In particular this code ensures that "im" gets // autocorrected to "I'm" instead of "in" and "id" gets autocorrected to // "I'd" instead of "is". (Bug 1164421) // // XXX In English, apostrophes and hyphens are the only punctuation that // commonly occurs within words. If other wordlists include other // punctuation, we may need to add them to the regular expression below. // // XXX In the future, we might want to generalize this to consider // accented forms when looking for approximate matches. If "jose" // was autocorrecting to "hose" instead of "José", for example, then // we might need to extend the definition of an approximate match here. // For now, I don't have any examples of corrections that are coming // out incorrectly for accented letters, though, so am keeping this // simple. // // We loop backward through the suggestions, bumping any approximate // match to the start of the list. This ensures that the highest ranked // approximate match comes before lower ranked approximate matches, // and all approximate matches come before non-matching suggestions. // var i = suggestions.length - 1; var approximateMatches = 0; // how many approximate matches we've bumped var lcInput = input.toLowerCase(); while(i > approximateMatches) { if (suggestions[i][0].toLowerCase().replace(/[-']/g, '') === lcInput) { // If this suggestion was an approximate match for the users input.. var match = suggestions[i]; // get the match, suggestions.splice(i, 1); // remove it, suggestions.unshift(match); // and put it back at the start. approximateMatches++; // Increment this instead of i-- here. } else { i--; // if not an approximate match move to next suggestion } } // Make sure we have no more than three words if (suggestions.length > 3) { // We want to keep at least a user dictionary word here (if the heighest // user dictionary word has weight >= 1). // If for the first two suggestions we see one from user dictionary, or // if we dropped a user dict suggestion above (matching user input), // we can just append the third suggestion. // Otherwise, we'll need to search through the remaining suggestions and // append the first user-dict suggestion we find (that has weight >= 1); // if we can't find any suitable user-dict suggestions, we just append // the third suggestion (i.e. whatever that has the largest frequency // in the remaining suggestions). var trimmedSuggestions = suggestions.slice(0, 2); var userDictionaryWordEncountered = inputDefinedInUserDict || (!!suggestions[0][2]) || (!!suggestions[1][2]); if (userDictionaryWordEncountered) { trimmedSuggestions.push(suggestions[2]); } else { userDictionaryWordEncountered = suggestions.slice(2).some(function(suggestion) { if (suggestion[2] && suggestion[1] >= USER_DICT_PREDICTION_BUMP_THRESHOLD) { trimmedSuggestions.push(suggestion); return true; } else { return false; } }); if (!userDictionaryWordEncountered) { trimmedSuggestions.push(suggestions[2]); } } suggestions = trimmedSuggestions; } // Now get an array of just the suggested words var words = suggestions.map(function(x) { return x[0]; }); // see whether words[0] and input have same length var lengthMismatch; switch (Math.abs(input.length - words[0].length)) { case 0: lengthMismatch = false; break; case 1: lengthMismatch = suggestions[0][1] < MIN_LENGTH_MISMATCH_THRESHOLD; break; default: lengthMismatch = true; break; } // Decide whether the first word is going to be an autocorrection. // If the user's input is already a valid word, then don't // autocorrect unless the first suggested word is more common than // the input. Note that if the first suggested word has a higher // weight even after whatever penalty is applied for not matching // exactly, then it is significantly more common than the actual input. // (This rule means that "ill" will autocorrect to "I'll", // "wont" to "won't", etc.) // Also, don't autocorrect if the input is a single letter and // the first word is more than a single letter. (But still autocorrect // "i" to "I") if (correcting && !correctionDisabled && (!inputIsSuggestion || suggestions[0][1] > inputWeight * AUTO_CORRECT_THRESHOLD) && (input.length > 1 || words[0].length === 1) && !lengthMismatch) { // Remember the word to use if the next character is a space. autoCorrection = words[0]; // Mark the auto-correction so the renderer can highlight it words[0] = '*' + words[0]; } else { autoCorrection = null; } keyboard.sendCandidates(words); } // // If the user selects one of the suggestions offered by this input method // the keyboard calls this method to tell us it has been selected. // We have to delete the current word, insert this new word, and // update our internal state to match. // word: the text displayed as the suggestion, might contain ellipsis // data: the actual data we need to output // function select(word, data) { var oldWord = wordBeforeCursor(); // A word divider is expected to append to the word when the // suggestion is selected. var newWord = data + SUGGESTION_SUFFIX; // The user has selected a suggestion, so we don't need to display // them anymore. Note that calling this function resets all the // autocorrect state. We'll reset much of that state below after // the word has been replace with the new one. dismissSuggestions(); return replaceBeforeCursor(oldWord, newWord).then(function() { // Remember the change we just made so we can revert it if the // next key is a backspace. If the word is reverted we disable // autocorrection for this word. revertFrom = newWord; revertTo = oldWord; // And update the keyboard capitalization state, if necessary updateCapitalization(); }); } function dismissSuggestions() { // Clear the list of candidates keyboard.sendCandidates([]); // Get rid of any autocorrection that is pending and reset the rest // of our state, too. lastSpaceTimestamp = 0; autoCorrection = null; revertTo = revertFrom = ''; disableOnRevert = false; correctionDisabled = false; } function setLayoutPage(page) { if (currentPage === PAGE_INDEX_DEFAULT) { // we don't want to reset gotNormalKey if we're already in an alternative // layout. gotNormalKey = false; } currentPage = page; } function setLayoutParams(params) { layoutParams = params; // We don't need to update the nearbyKeys when using number/digit layout. if (inputMode === 'verbatim') { return; } // XXX We call nearbyKeys() every time the keyboard pops up. // Maybe it would be better to compute it once in keyboard.js and // cache it. // We get called every time the keyboard case changes. Don't bother // passing this data to the prediction engine if nothing has changed. var newmap = findNearbyKeys(params); var serialized = JSON.stringify(newmap); if (serialized === serializedNearbyKeyMap) { return; } nearbyKeyMap = newmap; serializedNearbyKeyMap = serialized; if (worker) { worker.postMessage({ cmd: 'setNearbyKeys', args: [nearbyKeyMap]}); // Ask for new suggestions since the new layout may affect them. // (When switching from QWERTY to Dvorak, e.g.) updateSuggestions(); } } function findNearbyKeys(layout) { var nearbyKeys = {}; var keys = layout.keyArray; // Make sure that all the keycodes are lowercase, not uppercase for (var p = 0; p < keys.length; ++p) { keys[p].code = String.fromCharCode(keys[p].code).toLowerCase().charCodeAt(0); } // For each key, calculate the keys nearby. for (var n = 0; n < keys.length; ++n) { var key1 = keys[n]; if (SpecialKey(key1)) { continue; } var nearby = {}; for (var m = 0; m < keys.length; ++m) { if (m === n) { continue; // don't compare a key to itself } var key2 = keys[m]; if (SpecialKey(key2)) { continue; } var d = distance(key1, key2); if (d !== 0) { nearby[key2.code] = d; } } nearbyKeys[key1.code] = nearby; } return nearbyKeys; // Compute the inverse square distance between the center point of // two keys, using the radius of the key (where radius is defined // as the distance from the center of key1 to a corner of key1) // as the unit of measure. If the distance is greater than 2.5 // times the radius return 0 instead. function distance(key1, key2) { var cx1 = key1.x + key1.width / 2; var cy1 = key1.y + key1.height / 2; var cx2 = key2.x + key2.width / 2; var cy2 = key2.y + key2.height / 2; var radius = Math.sqrt(key1.width * key1.width / 4 + key1.height * key1.height / 4); var dx = (cx1 - cx2) / radius; var dy = (cy1 - cy2) / radius; // We calculate distance from center to center, but this gives // too high of a penalty to vertical keys, so normalize dx/dy dy /= (key1.height / key1.width); var distanceSquared = dx * dx + dy * dy; if (distanceSquared < 1) { console.warn('Keys too close', JSON.stringify(key1), JSON.stringify(key2)); return 0; } if (distanceSquared > 2.5 * 2.5) { return 0; } else { return 1 / distanceSquared; } } // Determine whether the key is a special character or a regular letter. // Special characters include backspace (8), return (13), and space (32). function SpecialKey(key) { switch (key.code) { case 0: case KeyEvent.DOM_VK_BACK_SPACE: case KeyEvent.DOM_VK_CAPS_LOCK: case KeyEvent.DOM_VK_RETURN: case KeyEvent.DOM_VK_ALT: case KeyEvent.DOM_VK_SPACE: return true; default: // anything else is not special return false; } } } function updateSuggestions(repeat) { // If the user hasn't enabled suggestions, or if they're not appropriate // for this input type, or are turned off by the input mode, do nothing if (!suggesting && !correcting) { return; } // If there is no dictionary language, do nothing if (!language) { return; } // If we are still loading the dictionary, do nothing if (getDictionaryDataPromise) { return; } // This shouldn't happen -- we previously allow layout to be selected // without the dictionary, but the build script has since prevent that. if (!worker) { console.error('latin.js: called updateSuggestions() w/o an worker.'); return; } // If we deferred suggestions because of a key repeat, clear that timer if (suggestionsTimer) { clearTimeout(suggestionsTimer); suggestionsTimer = null; } // If we're still repeating, reset the repeat timer. if (repeat) { suggestionsTimer = setTimeout(updateSuggestions, AUTOREPEAT_DELAY); return; } // If we're not at the end of a word, we want to clear any suggestions // that might already be there if (!atWordEnd()) { if (suggesting) { keyboard.sendCandidates([]); } return; } var word = wordBeforeCursor(); if (word) { // Defend against bug 879572 even though I can't reproduce it worker.postMessage({cmd: 'predict', args: [word]}); } } function updateCapitalization() { // If either the input mode or the input type is one that doesn't // want capitalization, then don't alter the state of the keyboard. // We however want to reset the shift key state triggered by the user, // regardless of the layout page the user is currently on. if (!capitalizing) { keyboard.setUpperCase({ isUpperCase: false }); return; } // Set the keyboard to uppercase or lowercase depending // on the text around the cursor: // // 1) If the cursor is at the start of the field: uppercase // // 2) If there is a non space character immediately before the cursor: // lowercase // // 3) If the first non-space character before the cursor is . ? or !: // uppercase // // 4) Otherwise: lowercase // if (cursor === 0) { keyboard.setUpperCase({ isUpperCase: true }); } else if (!isWhiteSpace(inputText.substring(cursor - 1, cursor))) { keyboard.setUpperCase({ isUpperCase: false }); } else if (atSentenceStart()) { keyboard.setUpperCase({ isUpperCase: true }); } else { keyboard.setUpperCase({ isUpperCase: false }); } } // Return true if all characters of s are uppercase. A character // is uppercase if toLowerCase() on that character is returns something // different than the character function isUpperCase(s) { var lc = s.toLowerCase(); for (var i = 0, n = s.length; i < n; i++) { if (s[i] === lc[i]) { return false; } } return true; } function isWhiteSpace(s) { return WS.test(s); } // We only offer suggestions if the cursor is at the end of a word // The character before the cursor must be a word character and // the cursor must be at the end of the input or the character after // must be whitespace. If there is a selection we never return true. function atWordEnd() { // If there is a selection we never want suggestions if (selection) { return false; } // If we're not at the end of the line and the character after the // cursor is not whitespace, don't offer a suggestion // Note that we purposely use WS here, not WORDSEP. if (cursor < inputText.length && !WS.test(inputText[cursor])) { return false; } // If the cursor is at position 0 then we're not at the end of a word if (cursor <= 0) { return false; } // We're at the end of a word if the character before the cursor is // not a word separator character var c = inputText[cursor - 1]; return !WORDSEP.test(c); } // Get the word before the cursor. Assumes that atWordEnd() is true function wordBeforeCursor() { for (var firstletter = cursor - 1; firstletter >= 0; firstletter--) { if (WORDSEP.test(inputText[firstletter])) { break; } } firstletter++; // firstletter is now the position of the start of the word and cursor is // the end of the word return inputText.substring(firstletter, cursor); } function atSentenceStart() { var i = cursor - 1; if (i === -1) { // This is the empty string case return true; } // Verify that the position before the cursor is whitespace if (!isWhiteSpace(inputText[i--])) { return false; } // Now skip any additional whitespace before that while (i >= 0 && isWhiteSpace(inputText[i])) { i--; } if (i < 0) { // whitespace all the way back to the end of the string return true; } var c = inputText[i]; return c === '.' || c === '?' || c === '!'; } function stateChange(state) { // TODO: Remove this and actually compare the state properties with // the internal states. if (state.ownAction) { return; } cursor = state.selectionStart; selection = (state.selectionEnd > state.selectionStart) ? state.selectionEnd : 0; inputText = state.text; dismissSuggestions(); updateSuggestions(); } if (!('PAGE_INDEX_DEFAULT' in window)) { window.PAGE_INDEX_DEFAULT = null; } }());