Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
1274 lines
43 KiB
JavaScript
Executable file
1274 lines
43 KiB
JavaScript
Executable file
/* -*- 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;
|
|
}
|
|
}());
|