UNPKG

jszhuyin

Version:

Smart Chinese Zhuyin Input Method in JavaScript.

1,241 lines (1,102 loc) 38.9 kB
'use strict'; (function(factory) { if (typeof module === 'object' && module.exports) { // CommonJS factory(module.exports, { BopomofoEncoder: require('./bopomofo_encoder.js'), JSZhuyinDataPackStorage: require('./storage.js').JSZhuyinDataPackStorage, CacheStore: require('./storage.js').CacheStore }); } else if (typeof self === 'object') { // Window or WorkerGlobalScope if (typeof self.BopomofoEncoder === 'undefined' || typeof self.JSZhuyinDataPackStorage === 'undefined' || typeof self.CacheStore === 'undefined') { throw new Error('Dependency not found.'); } factory(self, self); } }(function(exports, required) { /* global DataLoader */ var BopomofoEncoder = required.BopomofoEncoder; var JSZhuyinDataPackStorage = required.JSZhuyinDataPackStorage; var CacheStore = required.CacheStore; /** * A queue to run one action at a time. * @this {object} ActionQueue instance. */ var ActionQueue = exports.ActionQueue = function ActionQueue() { this.pendingActions = []; this.waiting = false; }; /** * Function to call to run each action. * @type {function} */ ActionQueue.prototype.handle = null; /** * Queue and action. The arguments will be kept. * @this {object} ActionQueue instance. */ ActionQueue.prototype.queue = function aq_queue(type, data, reqId) { if (this.waiting) { this.pendingActions.push([type, data, reqId]); return; } this.waiting = true; this.handle(type, data, reqId); }; /** * handle() are suppose to call done() when it finishes. * @this {object} ActionQueue instance. */ ActionQueue.prototype.done = function aq_done() { if (!this.waiting) { throw new Error('ActionQueue: ' + 'Calling queue.done() when we are not waiting.'); } var args = this.pendingActions.shift(); if (!args) { this.waiting = false; return; } this.handle.apply(this, args); }; var JSZhuyinCandidateMetadata = function() { this.dataMap = new Map(); // Start with a number other than any obvious ones to remove the meaning of // this incremental ID. this.nextId = 42; this.NULL_DATA = [0, 0]; }; JSZhuyinCandidateMetadata.prototype.NULL_ID = 0; JSZhuyinCandidateMetadata.prototype.saveData = function jcm_saveData(encodedSoundsLength, index) { this.dataMap.set(this.nextId, [ encodedSoundsLength, index ]); return this.nextId++; }; JSZhuyinCandidateMetadata.prototype.getData = function(id) { if (id === this.NULL_ID) { return this.NULL_DATA; } var data = this.dataMap.get(id); if (!data) { throw new Error('JSZhuyinCandidateMetadata: ' + 'Inexistent or outdated candidate.'); } return data; }; JSZhuyinCandidateMetadata.prototype.clear = function() { this.dataMap.clear(); }; var JSZhuyinComposedResultData = function(result, overflowData) { result = result || {}; this.str = result.str || ''; this.score = result.score || 0; this.index = result.index || 0; this.overflowData = overflowData; }; JSZhuyinComposedResultData.prototype.copy = function() { return new JSZhuyinComposedResultData(this, this.overflowData); }; var JSZhuyinQueryData = function(symbols, longestPhraseLength) { this.symbols = symbols; this.expendedEncodedSounds = BopomofoEncoder.encodeExpended(this.symbols); this.longestPhraseLength = longestPhraseLength; this._trimmedSymbols = undefined; this._trimmedFromEndSymbols = undefined; this._trimmedSymbolsCombinations = undefined; this._trimmedFromEndSymbolsCombinations = undefined; }; JSZhuyinQueryData.prototype.getTrimmedSymbols = function() { if (this._trimmedSymbols) { return this._trimmedSymbols; } return (this._trimmedSymbols = BopomofoEncoder.trimToLength(this.symbols, this.longestPhraseLength)); }; JSZhuyinQueryData.prototype.getTrimmedFromEndSymbols = function() { if (this._trimmedFromEndSymbols) { return this._trimmedFromEndSymbols; } return (this._trimmedFromEndSymbols = BopomofoEncoder.trimToLengthFromEnd( this.symbols, this.longestPhraseLength)); }; JSZhuyinQueryData.prototype.getTrimmedSymbolsCombinations = function() { if (this._trimmedSymbolsCombinations) { return this._trimmedSymbolsCombinations; } return (this._trimmedSymbolsCombinations = BopomofoEncoder.getSymbolCombinations(this.getTrimmedSymbols())); }; JSZhuyinQueryData.prototype.getTrimmedFromEndSymbolsCombinations = function() { if (this._trimmedFromEndSymbolsCombinations) { return this._trimmedFromEndSymbolsCombinations; } return (this._trimmedFromEndSymbolsCombinations = BopomofoEncoder.getSymbolCombinations(this.getTrimmedFromEndSymbols())); }; var JSZhuyinComposedCandidatesBuilder = function JSZhuyinComposedCandidatesBuilder() { this.cache = null; }; JSZhuyinComposedCandidatesBuilder.prototype.LONGEST_PHRASE_LENGTH = undefined; JSZhuyinComposedCandidatesBuilder.prototype.load = function(storage) { this.storage = storage; this.cache = new CacheStore(); }; JSZhuyinComposedCandidatesBuilder.prototype.unload = function() { this.cache = null; this.storage = null; }; JSZhuyinComposedCandidatesBuilder.prototype.addToCache = function(expendedEncodedSounds, data) { this.cache.add(expendedEncodedSounds, data); return data; }; JSZhuyinComposedCandidatesBuilder.prototype.cleanupCache = function(supersetCodes) { this.cache.cleanup(supersetCodes); }; JSZhuyinComposedCandidatesBuilder.prototype.getComposedCandidates = function(queryData) { var expendedEncodedSounds = queryData.expendedEncodedSounds; // The optimization here is based on the fact that the newly appended symbols // always forms compositions beginning with the previous results. // Remaining compositions with lower scores cannot possibly results // in higher score so it does not make sense to calculate them all and // rank them for every new symbols. Rather, it make more sense to cache the // results and only create possible compositions and results from them and // rank them. // The |previousFirstResults| array contains JSZhuyinCachedResult instances // from the cache, or, if the sliced expended encoded sounds has not be // calculated, we would calculate it here and put it into the cache. // // The final result will be the matched first composed result of the // unsliced expended encoded sound. // // Note that the result data of the final result and in the cache are both // calculated with |_getFirstComposedResult()|. var previousFirstResults = [ undefined ]; var lastComposedResultData; var i = 0; var n = expendedEncodedSounds.length; var slicedExpendedEncodedSounds; // This loop will ensure previousFirstResults is populated with // results from length = 1 to n, where length = n will happen to be the one // result we would need to output. while (++i <= n) { slicedExpendedEncodedSounds = expendedEncodedSounds.slice(0, i); lastComposedResultData = this.cache.get(slicedExpendedEncodedSounds); // Empty result will be cached as a |null|. A "not found" result is // explicitly an undefined. if (lastComposedResultData === undefined) { lastComposedResultData = this._getFirstComposedResult( slicedExpendedEncodedSounds, previousFirstResults); this.addToCache(slicedExpendedEncodedSounds, lastComposedResultData); } previousFirstResults[i] = lastComposedResultData; } return lastComposedResultData; }; JSZhuyinComposedCandidatesBuilder.prototype._getFirstComposedResult = function(expendedEncodedSounds, previousFirstResults) { var storage = this.storage; // We would only need to worry about an element in previousFirstResults // follow by exactly one phrase, instead of getting all possible // compositions, because it will always be ruled out. var composedResultDataArr = previousFirstResults .reduce(function(composedResultDataArr, previousFirstResult, i) { if (i !== 0 && previousFirstResult === null) { return composedResultDataArr; } var currentComposedResultDataArr = BopomofoEncoder .getSymbolCombinations( expendedEncodedSounds.slice(i), this.LONGEST_PHRASE_LENGTH) .map(function(symbolCodes) { return [storage.getIncompleteMatched(symbolCodes), symbolCodes]; }) .filter(function(symbolCodesResultData) { return !!symbolCodesResultData[0]; }) .map(function(symbolCodesResultData) { var symbolCodesFirstResult = symbolCodesResultData[0].getFirstResult(); var composedResultData; if (i !== 0) { composedResultData = previousFirstResult.copy(); composedResultData.str += symbolCodesFirstResult.str; composedResultData.score += symbolCodesFirstResult.score; composedResultData.index = symbolCodesFirstResult.index; } else { composedResultData = new JSZhuyinComposedResultData( symbolCodesFirstResult, [ symbolCodesFirstResult.str, BopomofoEncoder.decode(symbolCodesResultData[1]).length ]); } return composedResultData; }); if (currentComposedResultDataArr.length === 0) { // Make sure we always have at least one full candidate. // These composed candidates with symbols will have score=-Infinity. // When all entries are of score=-Infinity, the sorting function will // sort the last entrie to the top and it will happen to be the must // useful result. var composedResultData; if (previousFirstResult) { composedResultData = previousFirstResult.copy(); } else { var firstSymbols = BopomofoEncoder.decode(expendedEncodedSounds.slice(i, 1)); composedResultData = new JSZhuyinComposedResultData( null, [ firstSymbols, firstSymbols.length ]); } composedResultData.str += BopomofoEncoder.decode(expendedEncodedSounds.slice(i)); composedResultData.score = -Infinity; composedResultDataArr.push(composedResultData); return composedResultDataArr; } else { return composedResultDataArr.concat(currentComposedResultDataArr); } }.bind(this), /* composedResultDataArr */ []) .sort(function(a, b) { if (b.score > a.score) { return 1; } if (b.score < a.score) { return -1; } if (b.str > a.str) { return 1; } if (b.str < a.str) { return -1; } return 0; }); return composedResultDataArr[0]; }; var JSZhuyinPartialMatchingCandidatesBuilder = function() { }; JSZhuyinPartialMatchingCandidatesBuilder.prototype.load = function(storage) { this.storage = storage; }; JSZhuyinPartialMatchingCandidatesBuilder.prototype.unload = function() { this.cache = null; }; JSZhuyinPartialMatchingCandidatesBuilder.prototype.getCandidates = function(queryData) { var storage = this.storage; var insertFullyMatched = (queryData.getTrimmedSymbols() !== queryData.symbols); var dataPackResultDataArr = queryData.getTrimmedSymbolsCombinations() .map(function(symbolCodes) { var symbolCodeDataArr = []; var arr, symbolLength; var i = 0; var n = insertFullyMatched ? symbolCodes.length : symbolCodes.length - 1; while (i++ < n) { arr = symbolCodes.slice(0, i); symbolLength = BopomofoEncoder.decode(arr).length; symbolCodeDataArr.push([symbolLength, arr]); } return symbolCodeDataArr; }) .reduce(function(orderedSymbolCodeArrs, symbolCodeDataArr) { symbolCodeDataArr.forEach(function(symbolCodeData) { if (!orderedSymbolCodeArrs[symbolCodeData[0]]) { orderedSymbolCodeArrs[symbolCodeData[0]] = []; orderedSymbolCodeArrs[symbolCodeData[0]].push(symbolCodeData[1]); } else { var isDuplication = orderedSymbolCodeArrs[symbolCodeData[0]] .some(function(arr) { return (arr.length === symbolCodeData[1]) && arr.some(function(code, i) { return (code === symbolCodeData[1][i]); }); }); if (!isDuplication) { orderedSymbolCodeArrs[symbolCodeData[0]].push(symbolCodeData[1]); } } }); return orderedSymbolCodeArrs; }, /* orderedSymbolCodeArrs */ []) .map(function(symbolCodeArrs, symbolLength) { var allDataPackResult = symbolCodeArrs .map(function(symbolCodes) { if (!storage.getIncompleteMatched(symbolCodes)) { return null; } return storage.getIncompleteMatched(symbolCodes).getResults(); }) .filter(function(dataPackResult) { return dataPackResult !== null; }) .reduce(function(allDataPackResult, dataPackResult) { return allDataPackResult.concat(dataPackResult); }, /* allDataPackResult */ []) .sort(function(a, b) { if (b.score > a.score) { return 1; } if (b.score < a.score) { return -1; } if (b.str > a.str) { return 1; } if (b.str < a.str) { return -1; } return 0; }); if (!allDataPackResult.length) { return; } return [symbolLength, allDataPackResult]; }) .filter(function(dataPackResultData) { return dataPackResultData; }) .reverse(); return dataPackResultDataArr; }; /** * The main IME logic. * @this {object} JSZhuyin instance. */ var JSZhuyin = exports.JSZhuyin = function JSZhuyin() { this.storage = null; this.symbols = ''; this.confirmedPartIndex = 0; this.confirmedCharacters = ''; this.defaultCandidate = undefined; this.composedCandidatesBuilder = new JSZhuyinComposedCandidatesBuilder(); this.partiallyMatchedCandidatesBuilder = new JSZhuyinPartialMatchingCandidatesBuilder(); this.overflowCandidateString = ''; this.overflowCandidateSymbolLength = 0; this.queue = null; this.candidateMetadata = null; }; /** * Limit the length of the symbols in the compositions. * @type {number} */ JSZhuyin.prototype.MAX_SOUNDS_LENGTH = 48; /** * Longest possible phrase in the database, any longer than this will not be * matched * @type {Number} */ JSZhuyin.prototype.LONGEST_PHRASE_LENGTH = 6; /** * Suggest phrases after confirming characters. * @type {boolean} */ JSZhuyin.prototype.SUGGEST_PHRASES = true; /** * Allow re-order of symbol input. * Better error-handling for typing with hardware keyboard. */ JSZhuyin.prototype.REORDER_SYMBOLS = false; /** * When searching for matching words/phrases, consider these pairs of symbols * are interchangables. * Must be a string representing 2n sounds in Bopomofo symbols. * * Example string: 'ㄣㄥㄌㄖㄨㄛㄡ', making ㄣ interchangable with ㄥ and * ㄌ interchangable with ㄖ, and ㄨㄛ with ㄡ. * @type {string} */ JSZhuyin.prototype.INTERCHANGABLE_PAIRS = ''; /** * ArrayBuffer instance holding the database. * You may set this constant to skip asynchronization load() phase. * @type {object} */ JSZhuyin.prototype.DATA_ARRAY_BUFFER = null; /** * handleKey will handle every key if this is set to true. * Not applicable keys (e.g. a Backspace key when there isn't any symbol * to remove) will be discarded. * @type {Boolean} */ JSZhuyin.prototype.MUST_HANDLE_ALL_KEYS = false; /** * Overwritten path of database file. * @type {string} */ JSZhuyin.prototype.dataURL = ''; /** * Run when the loading is complete. * @type {function} */ JSZhuyin.prototype.onloadend = null; /** * Run when loading is successful. * @type {function} */ JSZhuyin.prototype.onload = null; /** * Run when unload. * @type {function} */ JSZhuyin.prototype.onunload = null; /** * Run when database download progresses. * @type {function} */ JSZhuyin.prototype.ondownloadprogress = null; /** * Run when error occours. * @type {function} */ JSZhuyin.prototype.onerror = null; /** * Run when an action is handled; receives reqId passed to the functions. * @type {function} */ JSZhuyin.prototype.onactionhandled = null; /** * Callback to call when the composition updates. * @type {function} */ JSZhuyin.prototype.oncompositionupdate = null; /** * Callback to call when the composition ends. * @type {function} */ JSZhuyin.prototype.oncompositionend = null; /** * Callback to call when candidate menu updates. * @type {function} */ JSZhuyin.prototype.oncandidateschange = null; /** * Handle a key with it's DOM UI Event Level 3 key value. * @param {string} key The key property of the key to handle, * should be the printable character or a registered * name in the spec. * @param {any} reqId ID of the request. * @return {boolean} Return true if the key will be handled async. * @this {object} JSZhuyin instance. */ JSZhuyin.prototype.handleKey = function jz_handleKey(key, reqId) { if (!this.queue) { throw new Error('JSZhuyin: You need to load() first.'); } if (typeof key !== 'string') { throw new Error('JSZhuyin: key passed to handleKey must be a string.'); } if (this.MUST_HANDLE_ALL_KEYS) { this.queue.queue('key', key, reqId); return true; } if (key.length === 1 && BopomofoEncoder.isBopomofoSymbol(key)) { // We must handle Bopomofo symbols. this.queue.queue('key', key, reqId); return true; } if (key.length > 1) { var isAllBopomofoSymbols = key.split('').every(function(chr) { return BopomofoEncoder.isBopomofoSymbol(chr); }); if (isAllBopomofoSymbols) { // We must handle Bopomofo symbols. this.queue.queue('key', key, reqId); return true; } } if (this.defaultCandidate || this.symbols) { // We must handle all the keys if there are pending symbols or candidates. this.queue.queue('key', key, reqId); return true; } return false; }; /** * Handle a key event with it's keyCode or charCode. Deprecated. * @param {number} code charCode of the keyboard event. * If charCode is 0, you should pass keyCode instead. * @param {any} reqId ID of the request. * @return {boolean} Return true if the key will be handled async. * @this {object} JSZhuyin instance. */ JSZhuyin.prototype.handleKeyEvent = function jz_handleKeyEvent(code, reqId) { var key; switch (code) { case 0x08: key = 'Backspace'; break; case 0x0d: key = 'Enter'; break; case 0x1b: key = 'Escape'; break; default: // XXX: We are considering everything reach here is a printable character. key = String.fromCharCode(code); } return this.handleKey(key, reqId); }; /** * Select a candidate. Will be handled in the action queue. * @param {object} candidate One of the candidate that was sent via * oncandidateschange callback. * @param {any} reqId ID of the request. * @this {object} JSZhuyin instance. */ JSZhuyin.prototype.selectCandidate = function jz_selCandi(candidate, reqId) { if (!Array.isArray(candidate) || typeof candidate[0] !== 'string' || typeof candidate[1] !== 'number') { throw new Error('JSZhuyin: ' + 'malformed candidate object in selectCandidate call.'); } this.queue.queue('candidateSelection', candidate, reqId); }; /** * Load JSZhuyin; loading the database and register callbacks, etc. * @this {object} JSZhuyin instance. */ JSZhuyin.prototype.load = function jz_load(data) { if (this.loaded) { throw new Error('Already loaded.'); } this.loaded = true; this.symbols = ''; this.defaultCandidate = undefined; this.queue = new ActionQueue(); this.queue.handle = this.handle.bind(this); this.storage = new JSZhuyinDataPackStorage(); this.candidateMetadata = new JSZhuyinCandidateMetadata(); this.composedCandidatesBuilder.load(this.storage); this.partiallyMatchedCandidatesBuilder.load(this.storage); if (this.DATA_ARRAY_BUFFER && !data) { data = this.DATA_ARRAY_BUFFER; } if (data instanceof ArrayBuffer) { this.storage.load(data); if (typeof this.onload === 'function') { this.onload(); } if (typeof this.onloadend === 'function') { this.onloadend(); } } else { this.dataLoader = new DataLoader(); if (this.dataURL) { this.dataLoader.DATA_URL = this.dataURL; } this.dataLoader.onerror = function(err) { if (typeof this.onerror === 'function') { this.onerror(err); } }.bind(this); this.dataLoader.onload = function() { this.storage.load(this.dataLoader.data); if (typeof this.onload === 'function') { this.onload(); } }.bind(this); this.dataLoader.onloadend = function() { if (typeof this.onloadend === 'function') { this.onloadend(); } }.bind(this); this.dataLoader.onprogress = function(progress) { if (typeof this.ondownloadprogress === 'function') { this.ondownloadprogress(progress); } }.bind(this); this.dataLoader.load(); } }; /** * Set configurations. * @param {object} config Configuration to set on JSZhuyin. */ JSZhuyin.prototype.setConfig = function(config) { for (var key in config) { this[key] = config[key]; } }; /** * Unload JSZhuyin. Queue a task to remove everything from memory. * @this {object} JSZhuyin instance. */ JSZhuyin.prototype.unload = function jz_unload() { if (!this.loaded) { throw new Error('Already unloaded.'); } this.queue.queue('unload'); }; /** * Unload JSZhuyin in this function loop. Remove everything from memory. * @this {object} JSZhuyin instance. */ JSZhuyin.prototype.unloadSync = function jz_unloadSync() { if (!this.loaded) { throw new Error('Already unloaded.'); } this.loaded = false; if (this.storage) { this.storage.unload(); this.storage = null; } if (this.dataLoader) { this.dataLoader = null; } if (this.composedCandidatesBuilder) { this.composedCandidatesBuilder.unload(); this.composedCandidatesBuilder = null; } if (this.partiallyMatchedCandidatesBuilder) { this.partiallyMatchedCandidatesBuilder.unload(); this.partiallyMatchedCandidatesBuilder = null; } this.symbols = ''; this.storage = null; this.defaultCandidate = undefined; this.queue.handle = null; this.queue = null; if (typeof this.onunload === 'function') { this.onunload(); } }; /** * Actual function to handle an action in the action queue. * You should not call this method directly. * @param {string} type A type keyword. * @param {any} data Data to handle for the action. * @param {any} reqId ID of the request. * @this {object} JSZhuyin instance. */ JSZhuyin.prototype.handle = function jz_handle(type, data, reqId) { switch (type) { case 'key': switch (data) { case 'Backspace': if (this.symbols.length === 0) { // Sliently discard the key here. Any meaningful response at // this stage would be throw the event back to the client, // which it would not be able to handle it either. this.sendActionHandled(reqId); this.queue.done(); break; } this.symbols = this.symbols.substr(0, this.symbols.length - 1); this.query(reqId); break; case 'Enter': if (!this.defaultCandidate) { // Sliently discard the key here. Any meaningful response at // this stage would be throw the event back to the client, // which it would not be able to handle it either. this.sendActionHandled(reqId); this.queue.done(); break; } this.confirmCandidate(this.defaultCandidate, reqId); break; case 'Escape': this.symbols = ''; this.query(reqId); break; default: if (data.length === 1 && BopomofoEncoder.isBopomofoSymbol(data)) { var mode = this.REORDER_SYMBOLS ? BopomofoEncoder.APPEND_MODE_REORDER : BopomofoEncoder.APPEND_MODE_NONE; this.symbols = BopomofoEncoder.appendToSymbols(this.symbols, data, mode); this.query(reqId); break; } if (data.length > 1) { var isAllBopomofoSymbols = data.split('').every(function(chr) { return BopomofoEncoder.isBopomofoSymbol(chr); }); if (isAllBopomofoSymbols) { this.symbols += data; this.query(reqId); break; } } if (!this.defaultCandidate) { // Sliently discard the key here. Any meaningful response at // this stage would be throw the event back to the client, // and for synthesize this key. this.sendActionHandled(reqId); this.queue.done(); break; } // All other keys. // XXX: We could only handle it as if it's a printable character here. if (this.defaultCandidate) { this.confirmCandidate( [this.defaultCandidate[0] + data, this.defaultCandidate[1]], reqId); } else { this.confirmCandidate([data, this.candidateMetadata.NULL_ID]); } break; } break; case 'candidateSelection': this.confirmCandidate(data, reqId); break; case 'unload': this.unloadSync(); break; default: throw new Error('Unknown action type: ' + type); } }; /** * Run the query against the current symbols. * You should not call this method directly. * @param {any} reqId ID of the request. * @this {object} JSZhuyin instance. */ JSZhuyin.prototype.query = function jz_query(reqId) { if (this.symbols.length === 0) { this.candidateMetadata.clear(); this.updateComposition(reqId); this.updateCandidates([], reqId); this.sendActionHandled(reqId); this.queue.done(); return; } // First, create the object that holds different forms of our current // symbols to query. var queryData = new JSZhuyinQueryData( this.symbols, this.LONGEST_PHRASE_LENGTH); // Confirming the overflow candidate if the lengths is too long. if (queryData.expendedEncodedSounds.length > this.MAX_SOUNDS_LENGTH) { this.endComposition(this.overflowCandidateString, reqId); this.symbols = this.symbols.substr(this.overflowCandidateSymbolLength); queryData = new JSZhuyinQueryData(this.symbols, this.LONGEST_PHRASE_LENGTH); } this.updateComposition(reqId); this.storage.setInterchangeablePairs(this.INTERCHANGABLE_PAIRS); // ==== START COMPOSING THE RESULT ==== var results = []; var storage = this.storage; this.overflowCandidateString = ''; this.overflowCandidateSymbolLength = 0; this.candidateMetadata.clear(); // ==== PART I: PHRASES ==== // List all the choices if the entire query happens to match to // phrases. results = this._insertFullyMatchingCandidates(results, queryData); // ==== PART II: COMPOSED RESULTS ==== // Insert one composed results, composed by the top-ranked phrases. results = this._insertFullyMatchingComposedCandidates(results, queryData); // ==== PART III: PHRASES THAT MATCHES SYMBOLS PARTIALLY ==== results = this._insertPartialMatchingCandidates(results, queryData); // ==== PART IV: UNFORTUNATE TYPO ==== // Lastly, append the symbols as the last candidate to give out a proper // feedback (if applicable). results = this._insertTypoHints(results, queryData.expendedEncodedSounds); this.updateCandidates(results, reqId); this.sendActionHandled(reqId); // Remove extra data in the composed result cache. this.composedCandidatesBuilder.cleanupCache(queryData.expendedEncodedSounds); // Flatten codes array of possible combinations from the beginning and end // for cache cleanning in storage. // (Disregard the middle part because they will be backed by composed result // caches.) var supersetCodes = [].concat( queryData.getTrimmedSymbolsCombinations(), queryData.getTrimmedFromEndSymbolsCombinations() ) .reduce(function(supersetCodes, symbolCodes) { return supersetCodes.concat(symbolCodes); }, /* supersetCodes */ []); // Remove cache entires from storage cache. storage.cleanupCache(supersetCodes); this.queue.done(); }; /** * Internal method for Part I of query(), fully matched candidates into the * result. * @param {array(array(string, number))} results Result candidates. * @param {object} queryData JSZhuyinQueryData * instance. * @return {array(array(string, number))} The result candidates array passed * (modified in-place). */ JSZhuyin.prototype._insertFullyMatchingCandidates = function(results, queryData) { if (queryData.getTrimmedSymbols() !== this.symbols) { return results; } var storage = this.storage; var expendedEncodedSounds = queryData.expendedEncodedSounds; var combinations = queryData.getTrimmedSymbolsCombinations(); combinations .map(function(symbolCodes) { // No need to search in the storage. if (symbolCodes.length > this.LONGEST_PHRASE_LENGTH) { return null; } return storage.getIncompleteMatched(symbolCodes); }.bind(this)) .reduce(function(resultsArr, dataPack) { if (!dataPack) { return resultsArr; } return resultsArr.concat(dataPack.getResults()); }, /* resultsArr */ []) .sort(function(a, b) { if (b.score > a.score) { return 1; } if (b.score < a.score) { return -1; } if (b.str > a.str) { return 1; } if (b.str < a.str) { return -1; } return 0; }) .forEach(function(result, i) { if (!this.overflowCandidateString) { this.overflowCandidateString = result.str; this.overflowCandidateSymbolLength = this.symbols.length; } if (i === 0) { this.composedCandidatesBuilder.addToCache( expendedEncodedSounds, new JSZhuyinComposedResultData( result, [ result.str, this.symbols.length ])); } results.push([result.str, this.candidateMetadata.saveData(this.symbols.length, result.index)]); }.bind(this)); return results; }; /** * Internal method for Part I of query(), fully matched candidates into the * result. * @param {array(array(string, number))} results Result candidates. * @param {object} queryData JSZhuyinQueryData * instance. * @return {array(array(string, number))} The result candidates array passed * (modified in-place). */ JSZhuyin.prototype._insertFullyMatchingComposedCandidates = function(results, queryData) { // We don't need to compose any results if there is already results from // part I. // Assuming Part II reult is always inferior than Part I result? if (results.length) { return results; } this.composedCandidatesBuilder.LONGEST_PHRASE_LENGTH = this.LONGEST_PHRASE_LENGTH; var composedResultData = this.composedCandidatesBuilder.getComposedCandidates(queryData); if (!this.overflowCandidateString) { this.overflowCandidateString = composedResultData.overflowData[0]; this.overflowCandidateSymbolLength = composedResultData.overflowData[1]; } // XXX: suggest() not only needs the lastPartIndex but also the last // part of the confirmed characters to work. results.push([composedResultData.str, this.candidateMetadata.saveData( this.symbols.length, composedResultData.index)]); return results; }; /** * Internal method for Part III of query(), insert partially matched candidates * into results object * @param {array(array(string, number))} results Result candidates. * @param {object} queryData JSZhuyinQueryData * instance. * @return {array(array(string, number))} The result candidates array passed * (modified in-place). */ JSZhuyin.prototype._insertPartialMatchingCandidates = function(results, queryData) { var dataPackResultDataArr = this.partiallyMatchedCandidatesBuilder.getCandidates(queryData); dataPackResultDataArr .forEach(function(dataPackResultData) { var symbolLength = dataPackResultData[0]; dataPackResultData[1].forEach(function(result) { var isDuplication = [].concat(results).reverse() .some(function(previousResult) { return (previousResult[0] === result.str); }); if (isDuplication) { return; } if (!this.overflowCandidateString) { this.overflowCandidateString = result.str; this.overflowCandidateSymbolLength = symbolLength; } var res = [result.str, this.candidateMetadata.saveData(symbolLength, result.index)]; results.push(res); }.bind(this)); }.bind(this)); return results; }; /** * Internal method for Part IV of query(), insert typo hints. * into results object * @param {array(array(string, number))} results Result * candidates. * @param {array(number)} expendedEncodedSounds Symbols encoded. * @return {array(array(string, number))} The result candidates array passed * (modified in-place). */ JSZhuyin.prototype._insertTypoHints = function(results, expendedEncodedSounds) { if (results.length === 1 && expendedEncodedSounds.length !== 1) { var symbols = BopomofoEncoder.decode([expendedEncodedSounds[0]]); results.push([symbols, this.candidateMetadata.saveData(symbols.length, 0)]); } return results; }; /** * Suggest possible phrases after the user had enter a term. * @param {any} reqId ID of the request. * @this {object} JSZhuyin instance. */ JSZhuyin.prototype.suggest = function jz_suggest(reqId) { this.candidateMetadata.clear(); if (this.confirmedPartIndex === this.candidateMetadata.NULL_ID || !this.SUGGEST_PHRASES) { this.updateCandidates([], reqId); this.sendActionHandled(reqId); this.queue.done(); return; } var suggests = []; var confirmedCharactersLength = this.confirmedCharacters.length; var results = this.storage.getRangeFromContentIndex(this.confirmedPartIndex); results.forEach(function each_suggest(dataPack) { var dataPackResults = dataPack.getResultsBeginsWith(this.confirmedCharacters); dataPackResults.forEach(function each_result(dataPackResult) { // Don't push duplicate entries. var found = suggests.some(function finddup(suggest) { return (dataPackResult.str === suggest.str); }); if (!found) { suggests.push(dataPackResult); } }); }.bind(this)); var candidates = []; suggests.sort(function sort_suggests(a, b) { if (b.score > a.score) { return 1; } if (b.score < a.score) { return -1; } if (b.str > a.str) { return 1; } if (b.str < a.str) { return -1; } return 0; }).forEach(function each_suggests(suggests) { candidates.push([ suggests.str.substr(confirmedCharactersLength), this.candidateMetadata.NULL_ID]); }, this); this.updateCandidates(candidates, reqId); this.sendActionHandled(reqId); this.queue.done(); }; /** * Update composition by call the oncompositionupdate callback. * You should not call this method directly. * @param {any} reqId ID of the request. * @this {object} JSZhuyin instance. */ JSZhuyin.prototype.updateComposition = function jz_updateComposition(reqId) { if (typeof this.oncompositionupdate === 'function') { this.oncompositionupdate(this.symbols, reqId); } }; /** * End composition by call the oncompositionend callback. * You should not call this method directly. * @param {string} str String to end composition with. * @param {any} reqId ID of the request. * @this {object} JSZhuyin instance. */ JSZhuyin.prototype.endComposition = function jz_endComposition(str, reqId) { if (typeof this.oncompositionend === 'function') { this.oncompositionend(str, reqId); } }; /** * Update the candidate with query results. * You should not call this method directly. * @param {array} results The result array. * @param {any} reqId ID of the request. * @this {object} JSZhuyin instance. */ JSZhuyin.prototype.updateCandidates = function jz_updateCandidates(results, reqId) { // Suggestion candidates cannot be confirmed as default candidate. if (results[0] && results[0][1]) { this.defaultCandidate = results[0]; } else { this.defaultCandidate = undefined; } if (typeof this.oncandidateschange === 'function') { this.oncandidateschange(results, reqId); } }; /** * Confirm a selection by calling the compositionend callback and run * the query again. * You should not call this method directly. * @param {object} candidate One of the candidate that was sent via * oncandidateschange callback. * @param {any} reqId ID of the request. * @this {object} JSZhuyin instance. */ JSZhuyin.prototype.confirmCandidate = function jz_confirmCandidate(candidate, reqId) { this.endComposition(candidate[0], reqId); this.confirmedCharacters = candidate[0]; var metadata = this.candidateMetadata.getData(candidate[1]); this.confirmedPartIndex = metadata[1]; this.symbols = this.symbols.substr(metadata[0]); if (this.symbols.length !== 0) { this.query(reqId); } else { this.updateComposition(reqId); this.suggest(reqId); } }; /** * Call the onactionhandled callback. * You should not call this method directly. * @param {any} reqId ID of the request. * @this {object} JSZhuyin instance. */ JSZhuyin.prototype.sendActionHandled = function jz_sendActionHandled(reqId) { if (typeof this.onactionhandled === 'function') { this.onactionhandled(reqId); } }; }));