UNPKG

jszhuyin

Version:

Smart Chinese Zhuyin Input Method in JavaScript.

652 lines (564 loc) 18.2 kB
'use strict'; /* global JSZhuyinClient, JSZhuyin */ // This file is used to run JSZhuyin within a desktop browser, // interacting with the user with an US Keyboard. var D3EKeyboardEventHelper = { getKeyPropFromEvent: function(evt) { if (evt.key) { return evt.key; } // There will always be a charCode if this event is a printable character, // or a keypress event of non-printable key from WebKit/Chrome. switch (evt.charCode) { case 0x0d: return 'Enter'; default: if (evt.charCode >= 0x20 && evt.charCode <= 0x7e) { return String.fromCharCode(evt.charCode); } } // If there is an old DOM3 keyIdentifier property, we should use it. // ref: // http://w3.org/TR/2007/WD-DOM-Level-3-Events-20071221/keyset#KeySet-Set // This is not a complete set, it only contains the keys our IME interests. if ('keyIdentifier' in evt) { switch (evt.keyIdentifier) { case 'U+0008': return 'Backspace'; case 'Enter': return 'Enter'; case 'U+001B': return 'Escape'; case 'U+0020': return ' '; case 'Left': return 'ArrowLeft'; case 'Right': return 'ArrowRight'; default: if (evt.keyIdentifier.substr(0, 2) === 'U+') { var charCode = parseInt(evt.keyIdentifier.substr(2), 16); // Only an U+XXXX code within this range should be trusted. if (charCode >= 0x20 && charCode <= 0x7e) { return String.fromCharCode(charCode); } } } } // Some other information extractable from keyCode. // XXX: This is not entirely correct since we don't know the state // of CapsLock, and we also assuming user is on US Layout, but at least // we have someting filled into the |key| value. // But the |code| value come up from these values should be correct. switch (evt.keyCode) { case 0x08: return 'Backspace'; case 0x0d: return 'Enter'; case 0x1b: return 'Escape'; case 0x20: return ' '; case 0x25: return 'ArrowLeft'; case 0x27: return 'ArrowRight'; case 0xc0: return evt.shiftKey ? '~' : '`'; case 0xbd: return evt.shiftKey ? '_' : '-'; case 0xbb: return evt.shiftKey ? '+' : '='; case 0xdb: return evt.shiftKey ? '{' : '['; case 0xdd: return evt.shiftKey ? '}' : ']'; case 0xdc: return evt.shiftKey ? '|' : '\\'; case 0xba: return evt.shiftKey ? ':' : ';'; case 0xde: return evt.shiftKey ? '"' : '\''; case 0xbc: return evt.shiftKey ? '<' : ','; case 0xbe: return evt.shiftKey ? '>' : '.'; case 0xbf: return evt.shiftKey ? '?' : '/'; default: if (!evt.shiftKey) { if (evt.keyCode >= 0x41 && evt.keyCode <= 0x5a) { // A-Z return String.fromCharCode(evt.keyCode).toLowerCase(); } if (evt.keyCode >= 0x30 && evt.keyCode <= 0x39) { // 0-9 return String.fromCharCode(evt.keyCode); } } else { if (evt.keyCode >= 0x41 && evt.keyCode <= 0x5a) { // A-Z return String.fromCharCode(evt.keyCode); } if (evt.keyCode >= 0x30 && evt.keyCode <= 0x39) { // 0-9 return ')!@#$%^&*('.charAt(evt.keyCode & 0x0f); } } } }, getCodePropFromEvent: function(evt) { if (evt.code) { return evt.code; } // Assuming user is on US Keyboard layout, we could get the code value from // the key value. var key = this.getKeyPropFromEvent(evt); if (!key) { return; } switch (key) { case 'Backspace': return 'Backspace'; case 'Enter': return 'Enter'; case 'Escape': return 'Escape'; case ' ': return 'Space'; case 'ArrowLeft': return 'ArrowLeft'; case 'ArrowRight': return 'ArrowRight'; case '~': case '`': return 'Backquote'; case '_': case '-': return 'Minus'; case '+': case '=': return 'Equal'; case '{': case '[': return 'BracketLeft'; case '}': case ']': return 'BracketRight'; case '\\': case '|': return 'Backslash'; case ':': case ';': return 'Semicolon'; case '"': case '\'': return 'Quote'; case '<': case ',': return 'Comma'; case '>': case '.': return 'Period'; case '?': case '/': return 'Slash'; case '!': return 'Digit1'; case '@': return 'Digit2'; case '#': return 'Digit3'; case '$': return 'Digit4'; case '%': return 'Digit5'; case '^': return 'Digit6'; case '&': return 'Digit7'; case '*': return 'Digit8'; case '(': return 'Digit9'; case ')': return 'Digit0'; default: if (/^[\d]$/.test(key)) { return 'Digit' + key; } if (/^[a-z]$/i.test(key)) { return 'Key' + key.toUpperCase(); } } } }; // Map the characters on the standard keyboard to Bopomofo layout. var JSZhuyinLayoutMapper = { // DOM Level 3 Events code values on PC Keyboard // http://www.w3.org/TR/DOM-Level-3-Events-code/#keyboard-key-codes codes: ['Backquote', 'Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Digit9', 'Digit0', 'Minus', 'Equal', 'KeyQ', 'KeyW', 'KeyE', 'KeyR', 'KeyT', 'KeyY', 'KeyU', 'KeyI', 'KeyO', 'KeyP', 'BracketLeft', 'BracketRight', 'Backslash', 'KeyA', 'KeyS', 'KeyD', 'KeyF', 'KeyG', 'KeyH', 'KeyJ', 'KeyK', 'KeyL', 'Semicolon', 'Quote', 'KeyZ', 'KeyX', 'KeyC', 'KeyV', 'KeyB', 'KeyN', 'KeyM', 'Comma', 'Period', 'Slash', 'Space'], // DOM Level 3 Events code values on PC Keyboard // for select candidate (with shift key) selectionCodes: ['Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Digit9'], // ... map to 'general' Zhuyin layout characters map: '⋯ㄅㄉˇˋㄓˊ˙ㄚㄞㄢㄦ=' + 'ㄆㄊㄍㄐㄔㄗㄧㄛㄟㄣ「」\' + 'ㄇㄋㄎㄑㄕㄘㄨㄜㄠㄤ、' + 'ㄈㄌㄏㄒㄖㄙㄩㄝㄡㄥ' + 'ˉ', // ... and characters when shift is pressed. shiftMap: '~!@#$%︿&*()—+' + 'qwertyuiop『』|' + 'asdfghjkl:;' + 'zxcvbnm,。?' + ' ', getSymbolFromDOM3Code: function(dom3Code, shiftKey) { var index = this.codes.indexOf(dom3Code); if (index === -1) { return; } if (shiftKey) { return this.shiftMap.charAt(index); } else { return this.map.charAt(index); } }, getSelectionIndexFromDOM3Code: function(dom3Code) { return this.selectionCodes.indexOf(dom3Code); } }; var JSZhuyinWebIME = function JSZhuyinWebIME(elements, jszhuyin) { this.EDITABLE_INPUT_TYPES = ['text', 'search', 'url', 'email']; this.elements = elements; window.addEventListener('keydown', this, true); window.addEventListener('keypress', this, true); if (!jszhuyin) { jszhuyin = (typeof JSZhuyinClient === 'function') ? new JSZhuyinClient() : new JSZhuyin(); jszhuyin.load(); } this.jszhuyin = jszhuyin; jszhuyin.oncompositionupdate = this.updateComposition.bind(this); jszhuyin.oncompositionend = this.endComposition.bind(this); jszhuyin.onactionhandled = this.updateHandledId.bind(this); jszhuyin.oncandidateschange = this.setCandidates.bind(this); var candidatesList = this.candidatesList = new JSZhuyinWebIME.CandidatesList(elements.candidatesList); candidatesList.oncandidateselect = this.selectCandidate.bind(this); this.isCompositionStarted = false; this.requestId = 0; this.handledId = 0; }; JSZhuyinWebIME.prototype.oncandidateschange = null; JSZhuyinWebIME.prototype.oncompositionupdate = null; JSZhuyinWebIME.prototype.oncompositionend = null; JSZhuyinWebIME.prototype.unload = function() { this.jszhuyin.unload(); this.candidatesList.unload(); window.removeEventListener('keydown', this, true); window.removeEventListener('keypress', this, true); }; JSZhuyinWebIME.prototype.setCandidates = function(candidates) { this.candidatesList.setCandidates(candidates); if (typeof this.oncandidateschange === 'function') { this.oncandidateschange(candidates); } }; JSZhuyinWebIME.prototype.selectCandidate = function(candidate) { // If we are in the "unstable" state, i.e. the candidate list might // be updated due to the next action, we would need to discard the // selection. if (this.requestId !== this.handledId) { return; } this.jszhuyin.selectCandidate(candidate, ++this.requestId); }; JSZhuyinWebIME.prototype.updateHandledId = function(id) { this.handledId = id; }; JSZhuyinWebIME.prototype.updateComposition = function(composition) { if (!this.isCompositionStarted && composition.length) { this.isCompositionStarted = true; this.sendCompositionEvent('compositionstart'); } this.sendCompositionEvent('compositionupdate', composition); this.elements.composition.textContent = composition; if (typeof this.oncompositionupdate === 'function') { this.oncompositionupdate(composition); } }; JSZhuyinWebIME.prototype.endComposition = function(composition) { if (!this.isCompositionStarted) { this.isCompositionStarted = true; this.sendCompositionEvent('compositionstart'); } this.sendCompositionEvent('compositionend', composition); this.isCompositionStarted = false; if (!this.elements.input) { throw new Error( 'JSZhuyinWebIME: There are no input focused to commit text to; ' + 'did the focus being taken by virtual keyboard ' + 'or the user has never focused to an input box?'); } // Commit the text to cursor position, poor man's way. if (!this.isContentEditable) { var input = this.elements.input; // <input> / <textarea> var selStart = input.selectionStart; input.value = input.value.substr(0, selStart) + composition + input.value.substr(input.selectionEnd, input.value.length); input.selectionStart = input.selectionEnd = selStart + composition.length; if (typeof this.oncompositionend === 'function') { this.oncompositionend(composition); } return; } // contenteditable var sel = window.getSelection(); // Remove the selection if there is any. if (!sel.isCollapsed) { sel.deleteFromDocument(); } // Get the current focused node. var node = sel.focusNode; var range; if (node.nodeName === '#text') { // Easy. We could simply update the node content and the cursor position. var offset = sel.focusOffset; // Update the content of the node. node.textContent = node.textContent.substr(0, offset) + composition + node.textContent.substr(offset, node.textContent.length); // Move it's cursor position. range = document.createRange(); range.setStart( (node.nodeName === '#text') ? node : node.firstChild, offset + composition.length); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); if (typeof this.oncompositionend === 'function') { this.oncompositionend(composition); } return; } // The current focusNode is an element. var textNode = document.createTextNode(composition); // Insert the text by insert the text node. var currentRange = sel.getRangeAt(0); currentRange.insertNode(textNode); // Move the cursor position to the text node. range = document.createRange(); range.setStart(textNode, composition.length); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); if (typeof this.oncompositionend === 'function') { this.oncompositionend(composition); } }; JSZhuyinWebIME.prototype.sendCompositionEvent = function() { // XXX }; JSZhuyinWebIME.prototype.handleKey = function(code) { var handled = this.jszhuyin.handleKey(code, ++this.requestId); if (!handled) { this.requestId--; } return handled; }; JSZhuyinWebIME.prototype.isElementEditable = function(el) { if (el.contentEditable === 'true') { return true; } if ('value' in el && this.EDITABLE_INPUT_TYPES.indexOf(el.type) !== -1) { return true; } return false; }; JSZhuyinWebIME.prototype.handleEvent = function(evt) { var el = evt.target; if (el !== this.elements.input) { // There is probably a better way to defense against race condition. if (this.requestId !== this.handledId) { evt.preventDefault(); return; } if (!this.isElementEditable(el)) { this.elements.input = null; return; } this.elements.input = el; this.isContentEditable = !('value' in el); } if (evt.metaKey || evt.ctrlKey || evt.altKey) { return; } var code = D3EKeyboardEventHelper.getCodePropFromEvent(evt); var specialKeys = (['Backspace', 'Enter', 'Escape', 'Space', 'ArrowLeft', 'ArrowRight'].indexOf(code) !== -1); // XXX Special quirks handling for Blink/WebKit: // For some keys, we must handle it in the keydown event because no // keypress event will be dispatched. // For other keys, we should handle keypress so we will know the // real key value. if (evt.type === 'keydown' && !specialKeys) { return; } else if (evt.type === 'keypress' && specialKeys) { return; } var shiftKey = evt.shiftKey; var handled; // Number keys: candidate selection var selectionIndex = JSZhuyinLayoutMapper.getSelectionIndexFromDOM3Code(code); if (evt.type === 'keypress' && shiftKey && selectionIndex !== -1) { handled = this.candidatesList.selectCandidate(selectionIndex); if (handled) { evt.preventDefault(); return; } } // right arrow if (code === 'ArrowRight') { if (!shiftKey) { return; } this.candidatesList.goForwardPage(); evt.preventDefault(); return; } // left arrow if (code === 'ArrowLeft') { if (!shiftKey) { return; } this.candidatesList.goBackPage(); evt.preventDefault(); return; } var symbol = JSZhuyinLayoutMapper.getSymbolFromDOM3Code(code, shiftKey); // Make sure we don't send any non-handled controls keys to JSZhuyin. if (!symbol && code !== 'Enter' && code !== 'Backspace' && code !== 'Escape') { // Block these keys ourselves if they are compositions. if (this.isCompositionStarted) { evt.preventDefault(); } return; } // For Enter and Backspace keys, ignore them if we have not handled the // previous action. This is poor man's way to defense against race condition. if (this.requestId !== this.handledId && code === 'Backspace' && code === 'Enter') { evt.preventDefault(); return; } var key = D3EKeyboardEventHelper.getKeyPropFromEvent(evt); if (this.handleKey(symbol || key)) { evt.preventDefault(); } else { // We must remove the suggestions ourselves here. this.setCandidates([]); if (symbol !== key && code !== 'Enter' && code !== 'Backspace' && code !== 'Escape') { this.endComposition(symbol || key); evt.preventDefault(); } } }; JSZhuyinWebIME.CandidatesList = function CandidatesList(element) { this.element = element; this.page = 0; this.element.addEventListener('mousedown', this); this.candidates = []; }; JSZhuyinWebIME.CandidatesList.prototype.unload = function() { this.element.removeEventListener('mousedown', this); }; JSZhuyinWebIME.CandidatesList.prototype.oncandidateselect = null; JSZhuyinWebIME.CandidatesList.prototype.CANDIDATES_PER_PAGE = 9; JSZhuyinWebIME.CandidatesList.prototype.setCandidates = function(candidates) { this.candidates = candidates; this.page = 0; this.showCandidates(); }; JSZhuyinWebIME.CandidatesList.prototype.goForwardPage = function() { if ((this.page + 1) * this.CANDIDATES_PER_PAGE >= this.candidates.length) { return; } this.page += 1; this.showCandidates(); }; JSZhuyinWebIME.CandidatesList.prototype.goBackPage = function() { if (this.page === 0) { return; } this.page -= 1; this.showCandidates(); }; JSZhuyinWebIME.CandidatesList.prototype.showCandidates = function(candidates) { var element = this.element; while (element.firstChild) { element.removeChild(element.firstChild); } var CPP = this.CANDIDATES_PER_PAGE; var pageBegin = this.page * CPP; this.candidates.slice(pageBegin, this.page * CPP + CPP) .forEach((function(candidate, i) { this.addCandidateItem(candidate, i, pageBegin + i); }).bind(this) ); if (pageBegin === 0) { this.element.classList.remove('can-go-left'); } else { this.element.classList.add('can-go-left'); } if ((this.page * CPP + CPP) >= this.candidates.length) { this.element.classList.remove('can-go-right'); } else { this.element.classList.add('can-go-right'); } }; JSZhuyinWebIME.CandidatesList.prototype.addCandidateItem = function(candidate, i, index) { var element = this.element; var li = document.createElement('li'); li.textContent = candidate[0]; li.setAttribute('data-index', index); element.appendChild(li); }; JSZhuyinWebIME.CandidatesList.prototype.selectCandidate = function(indexOnList) { var index = this.page * this.CANDIDATES_PER_PAGE + indexOnList; if (!this.candidates[index]) { return false; } if (typeof this.oncandidateselect === 'function') { this.oncandidateselect(this.candidates[index]); } return true; }; JSZhuyinWebIME.CandidatesList.prototype.handleEvent = function(evt) { var el = evt.target; var index = parseInt(el.getAttribute('data-index')); evt.preventDefault(); if (isNaN(index)) { return; } if (typeof this.oncandidateselect === 'function') { this.oncandidateselect(this.candidates[index]); } };