UNPKG

jszhuyin

Version:

Smart Chinese Zhuyin Input Method in JavaScript.

484 lines (418 loc) 14.4 kB
'use strict'; /* global JSZhuyinClient, JSZhuyinWebIME, JSZhuyinServerWorkerLoader */ var T = { LOADING: '載入中...', LOADING_WITH_PROGRESS: '載入中(%PROGRESS%)...', LOADED: '載入完成。', LOAD_ERROR: '資料檔案載入失敗,無法使用。', ERROR: '程式錯誤(%ERROR%)。' }; function ConfigDialog() { } ConfigDialog.prototype.CLOSE_BTN_ID = 'config-dialog-close'; ConfigDialog.prototype.OPEN_BTN_ID = 'config-btn'; ConfigDialog.prototype.DIALOG_ID = 'config-dialog-dialog'; ConfigDialog.prototype.onopened = null; ConfigDialog.prototype.start = function() { this.dialogEl = document.getElementById(this.DIALOG_ID); this.closeEl = document.getElementById(this.CLOSE_BTN_ID); this.openEl = document.getElementById(this.OPEN_BTN_ID); this.closeEl.addEventListener('click', this); this.openEl.addEventListener('click', this); this.openEl.classList.remove('disabled'); }; ConfigDialog.prototype.handleEvent = function(evt) { var target = evt.currentTarget; switch (target) { case this.openEl: this.dialogEl.classList.add('show'); if (typeof this.onopened === 'function') { this.onopened(); } break; case this.closeEl: this.dialogEl.classList.remove('show'); break; } }; function ConfigHandler() { } ConfigHandler.prototype.REORDER_SYMBOLS_ID = 'reorder-symbols'; ConfigHandler.prototype.INTERCHANGABLE_PAIRS_FORM_ID = 'interchangable-pairs-config'; ConfigHandler.prototype.onconfigupdate = null; ConfigHandler.prototype.start = function() { this.reorderEl = document.getElementById(this.REORDER_SYMBOLS_ID); this.reorderEl.addEventListener('click', this); this.interchangagblePairsEls = Array.prototype.slice.call( document.getElementById(this.INTERCHANGABLE_PAIRS_FORM_ID) .querySelectorAll('[data-pair]')); this.interchangagblePairsEls.forEach(function(el) { el.addEventListener('click', this); }, this); this.getConfigFromHash(); }; ConfigHandler.prototype.handleEvent = function(evt) { var target = evt.target; switch (target) { case this.reorderEl: this.reorderSymbols = target.checked; break; default: if (target.dataset.pair) { this.updateInterchangablePair(); } break; } this.setConfigToHash(); if (typeof this.onconfigupdate === 'function') { this.onconfigupdate(); } }; ConfigHandler.prototype.updateInterchangablePair = function() { var pairs = ''; this.interchangagblePairsEls.forEach(function(el) { if (el.checked) { pairs += el.dataset.pair; } }); this.interchangagblePairs = pairs; }; ConfigHandler.prototype.getConfigFromHash = function() { var hash = window.location.hash; this.reorderEl.checked = this.reorderSymbols = (hash.indexOf('reorder=0') === -1); this.interchangagblePairs = ''; var interchangagblePairs = decodeURIComponent((hash.match(/fuzzy=([^&]*)/) || ['',''])[1]); this.interchangagblePairsEls.forEach(function(el) { el.checked = (interchangagblePairs.indexOf(el.dataset.pair) !== -1); if (el.checked) { this.interchangagblePairs += el.dataset.pair; } }.bind(this)); }; ConfigHandler.prototype.setConfigToHash = function() { var hash = []; if (!this.reorderEl.checked) { hash.push('reorder=0'); } if (this.interchangagblePairs) { hash.push('fuzzy=' + this.interchangagblePairs); } window.location.replace('#' + hash.join('&')); }; ConfigHandler.prototype.getConfigObject = function() { return { 'REORDER_SYMBOLS': this.reorderSymbols, 'INTERCHANGABLE_PAIRS': this.interchangagblePairs }; }; function JSZhuyinServiceWorkerConfig() { } JSZhuyinServiceWorkerConfig.prototype.CLASS = 'sw-status'; JSZhuyinServiceWorkerConfig.prototype.ID_PREFIX = 'sw-status-'; JSZhuyinServiceWorkerConfig.prototype.onuserinstall = null; JSZhuyinServiceWorkerConfig.prototype.onuserremove = null; JSZhuyinServiceWorkerConfig.prototype.start = function() { this.installBtn = document.getElementById('sw-install'); this.removeBtn = document.getElementById('sw-remove'); this.installBtn.addEventListener('click', this); this.removeBtn.addEventListener('click', this); }; JSZhuyinServiceWorkerConfig.prototype.stop = function() { this.installBtn.removeEventListener('click', this); this.removeBtn.removeEventListener('click', this); this.installBtn = this.removeBtn = null; }; JSZhuyinServiceWorkerConfig.prototype.handleEvent = function(evt) { switch (evt.target) { case this.installBtn: if (typeof this.onuserinstall === 'function') { this.onuserinstall(); } break; case this.removeBtn: if (typeof this.onuserremove === 'function') { this.onuserremove(); } break; } }; JSZhuyinServiceWorkerConfig.prototype.setState = function(state) { var id = this.ID_PREFIX + state; var els = document.getElementsByClassName(this.CLASS); for (var i = 0; i < els.length; i++) { els[i].hidden = (id !== els[i].id); } }; function JSZhuyinServiceWorkerController() { } JSZhuyinServiceWorkerController.prototype.start = function() { this.config = new JSZhuyinServiceWorkerConfig(); this.config.start(); this.config.onuserinstall = this.install.bind(this); this.config.onuserremove = this.remove.bind(this); if (! ('serviceWorker' in navigator)) { this.config.setState('notsupported'); return; } this.install(); }; JSZhuyinServiceWorkerController.prototype.install = function() { this.config.setState('installing'); navigator.serviceWorker .register('service-worker.js', {scope: './'}) .then(function(swReg) { this.swReg = swReg; if (swReg.active) { this.config.setState('installed'); return; } var worker = swReg.installing; worker.addEventListener('statechange', function() { if (worker.state == 'redundant') { this.config.setState('uninstalled'); } if (worker.state == 'installed') { this.config.setState('installed'); } }.bind(this)); }.bind(this)) .catch(function(error) { console.error(error); this.config.setState('uninstalled'); }.bind(this)); }; JSZhuyinServiceWorkerController.prototype.remove = function() { this.config.setState('removing'); this.swReg.unregister() .then(function(unregistered) { if (unregistered) { this.config.setState('uninstalled'); this.swReg = null; } else { this.config.setState('installed'); } }.bind(this)); }; function JSZhuyinApp() { } JSZhuyinApp.prototype.start = function() { this._startNav(); this._startUI(); this._startEngine(); this._startServiceWorker(); }; JSZhuyinApp.prototype.handleEvent = function(evt) { switch (evt.type) { case 'mousemove': case 'blur': this.leaveDistractionFree(); break; case 'resize': this.positionPanel(); break; case 'compositionstart': case 'compositionupdate': this.showNativeIMEWarning(); break; } }; JSZhuyinApp.prototype._startNav = function() { var mobileNav = document.getElementById('mobile-nav'); mobileNav.selectedIndex = 0; mobileNav.addEventListener('change', function(evt) { window.location.href = mobileNav.value; }); }; JSZhuyinApp.prototype._startUI = function() { this.statusEl = document.getElementById('status'); this.statusEl.textContent = T.LOADING; this.panelEl = document.getElementById('panel'); this.inputareaEl = document.getElementById('inputarea'); this.compositionEl = document.getElementById('composition'); this.candidatesListEl = document.getElementById('candidates'); var configDialog = this.configDialog = new ConfigDialog(); configDialog.onopened = this.leaveDistractionFree.bind(this); configDialog.start(); var configHandler = this.configHandler = new ConfigHandler(); configHandler.start(); configHandler.onconfigupdate = function() { if (this.client.loaded) { this.client.setConfig(configHandler.getConfigObject()); } }.bind(this); }; JSZhuyinApp.prototype._startEngine = function() { var client = this.client = new JSZhuyinClient(); client.load(new JSZhuyinServerWorkerLoader()); client.ondownloadprogress = function(progressDict) { if (!progressDict.lengthComputable) { this.statusEl.textContent = T.LOADING_WITH_PROGRESS.replace( '%PROGRESS%', (progressDict.loaded / 1048576).toFixed(2) + ' MB'); } else { this.statusEl.textContent = T.LOADING_WITH_PROGRESS.replace('%PROGRESS%', (progressDict.loaded / progressDict.total * 100).toFixed(2) + '%'); } }.bind(this); client.onload = function() { this.inputareaEl.setAttribute('contenteditable', true); this.inputareaEl.focus(); this.inputareaEl.addEventListener('compositionstart', this); this.inputareaEl.addEventListener('compositionupdate', this); this.client.setConfig(this.configHandler.getConfigObject()); this.statusEl.textContent = T.LOADED; }.bind(this); client.onerror = function(err) { if (err.message.indexOf('DataLoader') !== -1) { this.statusEl.textContent = T.LOAD_ERROR; } else { this.leaveDistractionFree(); this.statusEl.textContent = T.ERROR.replace('%ERROR%', err.message); console.error(err); } }.bind(this); var webIME = this.webIME = new JSZhuyinWebIME({ composition: this.compositionEl, candidatesList: this.candidatesListEl }, client); // Overwrite the addCandidateItem() method because // we need an extra wrapped <span> for every item. webIME.candidatesList.addCandidateItem = function(candidate, index) { var element = this.element; var li = document.createElement('li'); li.setAttribute('data-index', index); var span = document.createElement('span'); span.textContent = candidate[0]; span.setAttribute('data-index', index); li.appendChild(span); element.appendChild(li); }; webIME.oncompositionend = (function(data) { this.updatePanelStyle(data); window._paq && window._paq.push( ['trackEvent', 'WebIME', 'compositionend', '', 1]); }).bind(this); webIME.oncompositionupdate = webIME.oncandidateschange = this.updatePanelStyle.bind(this); }; JSZhuyinApp.prototype._startServiceWorker = function() { this.swController = new JSZhuyinServiceWorkerController(); this.swController.start(); }; JSZhuyinApp.prototype.updatePanelStyle = function(data) { this.statusEl.textContent = ''; var hasData = (typeof data === 'string' && data) || (typeof data === 'object' && data.length); if (hasData) { this.hideNativeIMEWarning(); this.enterDistractionFree(); } if (!this.candidatesListEl.children.length && !this.compositionEl.textContent.length) { this.panelEl.setAttribute('hidden', 'true'); window.removeEventListener('resize', this); } else { this.panelEl.removeAttribute('hidden'); window.addEventListener('resize', this); this.positionPanel(); } }; JSZhuyinApp.prototype.showNativeIMEWarning = function() { this.leaveDistractionFree(); var nativeIMEWarning = this._nativeIMEWarning = this._nativeIMEWarning || new JSZhuyinAppNativeIMEWarning(); nativeIMEWarning.start(); this.inputareaEl.parentNode .insertBefore(nativeIMEWarning.element, this.inputareaEl); nativeIMEWarning.show(); // Discard the current compositions and candidates. // XXX: Implement and move this to cancelComposition() this.webIME.handleKey('Escape'); }; JSZhuyinApp.prototype.hideNativeIMEWarning = function() { if (this._nativeIMEWarning) { this._nativeIMEWarning.hide(); } }; JSZhuyinApp.prototype.positionPanel = function() { var sel = window.getSelection(); // XXX sync reflow, improve this. var isFloating = window.getComputedStyle(this.panelEl) .getPropertyValue('position') === 'absolute'; if (!isFloating) { // No need to calculate the position, goodbye! return; } var rect; rect = sel.getRangeAt(0).getBoundingClientRect(); if (rect.width === 0 && rect.left === 0 && rect.right === 0) { // Empty rect?! Maybe because the caret is not placed in a text node, // We need to get a rect by considering the node (probably <br>) // before the caret. var el = sel.focusNode.childNodes[sel.focusOffset]; if (el) { rect = el.getBoundingClientRect(); } else { // This only happens if inputarea is emptied, or if use have // pasted empty element into our inputarea. rect = sel.focusNode.getBoundingClientRect(); } } if (rect.width === 0 && rect.left === 0 && rect.right === 0) { // Still empty rect?! Let's use the safe fallback. rect = this.inputareaEl.getBoundingClientRect(); } this.panelEl.style.top = ( document.documentElement.scrollTop + document.body.scrollTop + rect.top + rect.height).toFixed(2) + 'px'; var panelRect = this.panelEl.getBoundingClientRect(); if (rect.left + panelRect.width < document.body.getBoundingClientRect().width) { this.panelEl.style.left = rect.left.toFixed(2) + 'px'; this.panelEl.style.right = ''; } else { this.panelEl.style.left = ''; this.panelEl.style.right = '0'; } if (document.documentElement.clientHeight < (panelRect.top + panelRect.height)) { this.panelEl.scrollIntoView(); } }; JSZhuyinApp.prototype.enterDistractionFree = function() { window.addEventListener('mousemove', this); this.inputareaEl.addEventListener('blur', this); document.body.className = 'distraction-free'; }; JSZhuyinApp.prototype.leaveDistractionFree = function() { window.removeEventListener('mousemove', this); this.inputareaEl.removeEventListener('blur', this); document.body.className = ''; }; window.app = new JSZhuyinApp(); window.onload = function() { window.onload = null; window.app.start(); }; function JSZhuyinAppNativeIMEWarning() { } JSZhuyinAppNativeIMEWarning.prototype.start = function() { if (this.element) { return; } var element = this.element = document.createElement('div'); element.className = 'alert alert-danger'; element.textContent = '關閉系統的輸入法以使用此網站上的注音輸入。'; }; JSZhuyinAppNativeIMEWarning.prototype.show = function() { this.element.hidden = false; }; JSZhuyinAppNativeIMEWarning.prototype.hide = function() { this.element.hidden = true; };