1 /** 2 * Copyright (C) 2013 KO GmbH <copyright@kogmbh.com> 3 * 4 * @licstart 5 * This file is part of WebODF. 6 * 7 * WebODF is free software: you can redistribute it and/or modify it 8 * under the terms of the GNU Affero General Public License (GNU AGPL) 9 * as published by the Free Software Foundation, either version 3 of 10 * the License, or (at your option) any later version. 11 * 12 * WebODF is distributed in the hope that it will be useful, but 13 * WITHOUT ANY WARRANTY; without even the implied warranty of 14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 * GNU Affero General Public License for more details. 16 * 17 * You should have received a copy of the GNU Affero General Public License 18 * along with WebODF. If not, see <http://www.gnu.org/licenses/>. 19 * @licend 20 * 21 * @source: http://www.webodf.org/ 22 * @source: https://github.com/kogmbh/WebODF/ 23 */ 24 25 /*global runtime, gui, core, ops, Node*/ 26 27 (function () { 28 "use strict"; 29 30 /** 31 * When composition session is ended on Safari under MacOS by pressing 32 * another text char, Safari incorrectly reports the next keypress event’s 33 * “which” value as the last compositionend data, rather than the new key 34 * that was pressed. 35 * 36 * This class will filter out these bad key presses, and emit the actual 37 * text as if it occurred via a normal composition event 38 * 39 * @constructor 40 * @implements {core.Destroyable} 41 * @param {!gui.EventManager} eventManager 42 */ 43 function DetectSafariCompositionError(eventManager) { 44 var lastCompositionValue, 45 suppressedKeyPress = false; 46 47 /** 48 * Detect and filter out bad Safari key presses 49 * @param {!(Event|KeyboardEvent)} e 50 * @return {!boolean} 51 */ 52 function suppressIncorrectKeyPress(e) { 53 suppressedKeyPress = e.which && String.fromCharCode(e.which) === lastCompositionValue; 54 lastCompositionValue = undefined; 55 return suppressedKeyPress === false; 56 } 57 58 function clearSuppression() { 59 suppressedKeyPress = false; 60 } 61 62 /** 63 * @param {!CompositionEvent} e 64 */ 65 function trapComposedValue(e) { 66 lastCompositionValue = e.data; 67 suppressedKeyPress = false; 68 } 69 70 function init() { 71 eventManager.subscribe("textInput", clearSuppression); 72 eventManager.subscribe("compositionend", trapComposedValue); 73 eventManager.addFilter("keypress", suppressIncorrectKeyPress); 74 } 75 76 /** 77 * @param {function(!Error=)} callback 78 */ 79 this.destroy = function (callback) { 80 eventManager.unsubscribe("textInput", clearSuppression); 81 eventManager.unsubscribe("compositionend", trapComposedValue); 82 eventManager.removeFilter("keypress", suppressIncorrectKeyPress); 83 callback(); 84 }; 85 86 init(); 87 } 88 89 /** 90 * Challenges of note: 91 * - On FF & Chrome, the composition session is interrupted if the OdtCursor moves 92 * - On Safari, using Option+char incorrectly reports the following keypress event 93 * - On Chrome, using Option+char will include the following keypress in the composition event 94 * 95 * @constructor 96 * @implements {core.Destroyable} 97 * @param {!string} inputMemberId 98 * @param {!gui.EventManager} eventManager 99 */ 100 gui.InputMethodEditor = function InputMethodEditor(inputMemberId, eventManager) { 101 var cursorns = "urn:webodf:names:cursor", 102 /**@type{ops.OdtCursor}*/ 103 localCursor = null, 104 eventTrap = eventManager.getEventTrap(), 105 /**@type{!Document}*/ 106 doc = /**@type{!Document}*/(eventTrap.ownerDocument), 107 /**@type{!Element}*/ 108 compositionElement, 109 FAKE_CONTENT = "b", 110 /**@type{!core.ScheduledTask}*/ 111 processUpdates, 112 pendingEvent = false, 113 /**@type{string}*/ 114 pendingData = "", 115 events = new core.EventNotifier([gui.InputMethodEditor.signalCompositionStart, 116 gui.InputMethodEditor.signalCompositionEnd]), 117 lastCompositionData, 118 filters = [], 119 cleanup; 120 121 /** 122 * Subscribe to IME events 123 * @type {Function} 124 */ 125 this.subscribe = events.subscribe; 126 127 /** 128 * Unsubscribe from IME events 129 * @type {Function} 130 */ 131 this.unsubscribe = events.unsubscribe; 132 133 /** 134 * Set the local cursor's current composition state. If there is no local cursor, 135 * this function will do nothing 136 * @param {!boolean} state 137 * @return {undefined} 138 */ 139 function setCursorComposing(state) { 140 if (localCursor) { 141 if (state) { 142 localCursor.getNode().setAttributeNS(cursorns, "composing", "true"); 143 } else { 144 localCursor.getNode().removeAttributeNS(cursorns, "composing"); 145 compositionElement.textContent = ""; 146 } 147 } 148 } 149 150 function flushEvent() { 151 if (pendingEvent) { 152 pendingEvent = false; 153 setCursorComposing(false); 154 events.emit(gui.InputMethodEditor.signalCompositionEnd, {data: pendingData}); 155 pendingData = ""; 156 } 157 } 158 159 /** 160 * @param {string} data 161 */ 162 function addCompositionData(data) { 163 pendingEvent = true; 164 pendingData += data; 165 // A delay is necessary as modifying document text and moving the cursor will interrupt 166 // back-to-back composition sessions (e.g., repeatedly pressing Option+char on MacOS in Chrome) 167 processUpdates.trigger(); 168 } 169 170 /** 171 * Synchronize the window's selection to the local user's cursor selection. This only changes whether the 172 * selection is a Range or a Caret (i.e., non-collapsed or collapsed). This allows most browsers to properly 173 * enable the cut/copy/paste items + shortcut keys. 174 * 175 * @return {undefined} 176 */ 177 function synchronizeWindowSelection() { 178 flushEvent(); 179 180 // If there is a local cursor, and it is collapsed, collapse the window selection as well. 181 // Otherwise, ensure some text is selected by default. 182 // A browser selection in an editable area is necessary to allow cut/copy events to fire 183 // It doesn't have to be an accurate selection however as the SessionController will override 184 // the default browser handling. 185 if (localCursor && localCursor.getSelectedRange().collapsed) { 186 eventTrap.value = ""; 187 } else { 188 // Content is necessary for cut/copy/paste to be enabled 189 eventTrap.value = FAKE_CONTENT; 190 } 191 192 eventTrap.setSelectionRange(0, eventTrap.value.length); 193 } 194 195 /** 196 * If the document has focus, queue up a window selection synchronization action to occur. If the document does 197 * not have focus, no action is necessary as the window selection will be resynchronized when focus is returned. 198 * @return {undefined} 199 */ 200 function handleCursorUpdated() { 201 if (eventManager.hasFocus()) { 202 processUpdates.trigger(); 203 } 204 } 205 206 function compositionStart() { 207 lastCompositionData = undefined; 208 // Some IMEs will stack end & start requests back to back. 209 // Aggregate these as a group and report them in a single request once all are 210 // complete to avoid the selection being reset 211 processUpdates.cancel(); 212 setCursorComposing(true); 213 if (!pendingEvent) { 214 events.emit(gui.InputMethodEditor.signalCompositionStart, {data: ""}); 215 } 216 } 217 218 /** 219 * @param {!CompositionEvent} e 220 */ 221 function compositionEnd(e) { 222 lastCompositionData = e.data; 223 addCompositionData(e.data); 224 } 225 226 /** 227 * @param {!Text} e 228 */ 229 function textInput(e) { 230 if (e.data !== lastCompositionData) { 231 // Chrome/Safari fire a compositionend event with data & a textInput event with data 232 // Firefox only fires a compositionend event with data (textInput is not supported) 233 // Chrome linux IME fires a compositionend event with no data, and a textInput event with data 234 addCompositionData(e.data); 235 } 236 lastCompositionData = undefined; 237 } 238 239 /** 240 * Synchronizes the eventTrap's text with 241 * the compositionElement's text. 242 * @return {undefined} 243 */ 244 function synchronizeCompositionText() { 245 compositionElement.textContent = eventTrap.value; 246 } 247 248 /** 249 * Handle a cursor registration event 250 * @param {!ops.OdtCursor} cursor 251 * @return {undefined} 252 */ 253 this.registerCursor = function (cursor) { 254 if (cursor.getMemberId() === inputMemberId) { 255 localCursor = cursor; 256 localCursor.getNode().appendChild(compositionElement); 257 cursor.subscribe(ops.OdtCursor.signalCursorUpdated, handleCursorUpdated); 258 eventManager.subscribe('input', synchronizeCompositionText); 259 eventManager.subscribe('compositionupdate', synchronizeCompositionText); 260 } 261 }; 262 263 /** 264 * Handle a cursor removal event 265 * @param {!string} memberid Member id of the removed cursor 266 * @return {undefined} 267 */ 268 this.removeCursor = function (memberid) { 269 if (localCursor && memberid === inputMemberId) { 270 localCursor.getNode().removeChild(compositionElement); 271 localCursor.unsubscribe(ops.OdtCursor.signalCursorUpdated, handleCursorUpdated); 272 eventManager.unsubscribe('input', synchronizeCompositionText); 273 eventManager.unsubscribe('compositionupdate', synchronizeCompositionText); 274 localCursor = null; 275 } 276 }; 277 278 /** 279 * @param {function(!Error=)} callback 280 */ 281 this.destroy = function (callback) { 282 eventManager.unsubscribe('compositionstart', compositionStart); 283 eventManager.unsubscribe('compositionend', compositionEnd); 284 eventManager.unsubscribe('textInput', textInput); 285 eventManager.unsubscribe('keypress', flushEvent); 286 eventManager.unsubscribe('focus', synchronizeWindowSelection); 287 288 core.Async.destroyAll(cleanup, callback); 289 }; 290 291 function init() { 292 eventManager.subscribe('compositionstart', compositionStart); 293 eventManager.subscribe('compositionend', compositionEnd); 294 eventManager.subscribe('textInput', textInput); 295 eventManager.subscribe('keypress', flushEvent); 296 eventManager.subscribe('focus', synchronizeWindowSelection); 297 298 filters.push(new DetectSafariCompositionError(eventManager)); 299 /** 300 * @param {{destroy:function()}} filter 301 * return {function()} 302 */ 303 function getDestroy(filter) { 304 return filter.destroy; 305 } 306 cleanup = filters.map(getDestroy); 307 308 // Initialize the composition element 309 compositionElement = doc.createElement('span'); 310 compositionElement.setAttribute('id', 'composer'); 311 312 processUpdates = core.Task.createTimeoutTask(synchronizeWindowSelection, 1); 313 cleanup.push(processUpdates.destroy); 314 } 315 316 init(); 317 }; 318 319 /** 320 * @const 321 * @type {!string} 322 */ 323 gui.InputMethodEditor.signalCompositionStart = "input/compositionstart"; 324 325 /** 326 * @const 327 * @type {!string} 328 */ 329 gui.InputMethodEditor.signalCompositionEnd = "input/compositionend"; 330 }()); 331