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, odf*/ 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 /**@type{!core.ScheduledTask}*/ 110 processUpdates, 111 pendingEvent = false, 112 /**@type{string}*/ 113 pendingData = "", 114 events = new core.EventNotifier([gui.InputMethodEditor.signalCompositionStart, 115 gui.InputMethodEditor.signalCompositionEnd]), 116 lastCompositionData, 117 /**@type{!odf.TextSerializer}*/ 118 textSerializer, 119 filters = [], 120 cleanup, 121 processingFocusEvent = false; 122 123 /** 124 * Subscribe to IME events 125 * @type {Function} 126 */ 127 this.subscribe = events.subscribe; 128 129 /** 130 * Unsubscribe from IME events 131 * @type {Function} 132 */ 133 this.unsubscribe = events.unsubscribe; 134 135 /** 136 * Set the local cursor's current composition state. If there is no local cursor, 137 * this function will do nothing 138 * @param {!boolean} state 139 * @return {undefined} 140 */ 141 function setCursorComposing(state) { 142 if (localCursor) { 143 if (state) { 144 localCursor.getNode().setAttributeNS(cursorns, "composing", "true"); 145 } else { 146 localCursor.getNode().removeAttributeNS(cursorns, "composing"); 147 compositionElement.textContent = ""; 148 } 149 } 150 } 151 152 function flushEvent() { 153 if (pendingEvent) { 154 pendingEvent = false; 155 setCursorComposing(false); 156 events.emit(gui.InputMethodEditor.signalCompositionEnd, {data: pendingData}); 157 pendingData = ""; 158 } 159 } 160 161 /** 162 * @param {string} data 163 */ 164 function addCompositionData(data) { 165 pendingEvent = true; 166 pendingData += data; 167 // A delay is necessary as modifying document text and moving the cursor will interrupt 168 // back-to-back composition sessions (e.g., repeatedly pressing Option+char on MacOS in Chrome) 169 processUpdates.trigger(); 170 } 171 172 /** 173 * Synchronize the window's selection to the local user's cursor selection. This only changes whether the 174 * selection is a Range or a Caret (i.e., non-collapsed or collapsed). This allows most browsers to properly 175 * enable the cut/copy/paste items + shortcut keys. 176 * 177 * @return {undefined} 178 */ 179 function synchronizeWindowSelection() { 180 if (processingFocusEvent) { 181 // Prevent infinite focus-stealing loops. If a focus event was already in progress, do nothing 182 // on the second loop 183 return; 184 } 185 processingFocusEvent = true; 186 flushEvent(); 187 188 // If there is a local cursor, and it is collapsed, collapse the window selection as well. 189 // Otherwise, ensure some text is selected by default. 190 // A browser selection in an editable area is necessary to allow cut/copy events to fire 191 // It doesn't have to be an accurate selection however as the SessionController will override 192 // the default browser handling. 193 if (localCursor && localCursor.getSelectedRange().collapsed) { 194 eventTrap.value = ""; 195 } else { 196 // Content is necessary for cut/copy/paste to be enabled 197 // TODO Improve performance by rewriting to not clone the range contents 198 eventTrap.value = textSerializer.writeToString(localCursor.getSelectedRange().cloneContents()); 199 } 200 201 eventTrap.setSelectionRange(0, eventTrap.value.length); 202 processingFocusEvent = false; 203 } 204 205 /** 206 * If the document has focus, queue up a window selection synchronization action to occur. If the document does 207 * not have focus, no action is necessary as the window selection will be resynchronized when focus is returned. 208 * @return {undefined} 209 */ 210 function handleCursorUpdated() { 211 if (eventManager.hasFocus()) { 212 processUpdates.trigger(); 213 } 214 } 215 216 function compositionStart() { 217 lastCompositionData = undefined; 218 // Some IMEs will stack end & start requests back to back. 219 // Aggregate these as a group and report them in a single request once all are 220 // complete to avoid the selection being reset 221 processUpdates.cancel(); 222 setCursorComposing(true); 223 if (!pendingEvent) { 224 events.emit(gui.InputMethodEditor.signalCompositionStart, {data: ""}); 225 } 226 } 227 228 /** 229 * @param {!CompositionEvent} e 230 */ 231 function compositionEnd(e) { 232 lastCompositionData = e.data; 233 addCompositionData(e.data); 234 } 235 236 /** 237 * @param {!Text} e 238 */ 239 function textInput(e) { 240 if (e.data !== lastCompositionData) { 241 // Chrome/Safari fire a compositionend event with data & a textInput event with data 242 // Firefox only fires a compositionend event with data (textInput is not supported) 243 // Chrome linux IME fires a compositionend event with no data, and a textInput event with data 244 addCompositionData(e.data); 245 } 246 lastCompositionData = undefined; 247 } 248 249 /** 250 * Synchronizes the eventTrap's text with 251 * the compositionElement's text. 252 * @return {undefined} 253 */ 254 function synchronizeCompositionText() { 255 compositionElement.textContent = eventTrap.value; 256 } 257 258 /** 259 * Handle a cursor registration event 260 * @param {!ops.OdtCursor} cursor 261 * @return {undefined} 262 */ 263 this.registerCursor = function (cursor) { 264 if (cursor.getMemberId() === inputMemberId) { 265 localCursor = cursor; 266 localCursor.getNode().appendChild(compositionElement); 267 cursor.subscribe(ops.OdtCursor.signalCursorUpdated, handleCursorUpdated); 268 eventManager.subscribe('input', synchronizeCompositionText); 269 eventManager.subscribe('compositionupdate', synchronizeCompositionText); 270 } 271 }; 272 273 /** 274 * Handle a cursor removal event 275 * @param {!string} memberid Member id of the removed cursor 276 * @return {undefined} 277 */ 278 this.removeCursor = function (memberid) { 279 if (localCursor && memberid === inputMemberId) { 280 localCursor.getNode().removeChild(compositionElement); 281 localCursor.unsubscribe(ops.OdtCursor.signalCursorUpdated, handleCursorUpdated); 282 eventManager.unsubscribe('input', synchronizeCompositionText); 283 eventManager.unsubscribe('compositionupdate', synchronizeCompositionText); 284 localCursor = null; 285 } 286 }; 287 288 /** 289 * @param {function(!Error=)} callback 290 */ 291 this.destroy = function (callback) { 292 eventManager.unsubscribe('compositionstart', compositionStart); 293 eventManager.unsubscribe('compositionend', compositionEnd); 294 eventManager.unsubscribe('textInput', textInput); 295 eventManager.unsubscribe('keypress', flushEvent); 296 eventManager.unsubscribe('focus', synchronizeWindowSelection); 297 298 core.Async.destroyAll(cleanup, callback); 299 }; 300 301 function init() { 302 textSerializer = new odf.TextSerializer(); 303 textSerializer.filter = new odf.OdfNodeFilter(); 304 305 eventManager.subscribe('compositionstart', compositionStart); 306 eventManager.subscribe('compositionend', compositionEnd); 307 eventManager.subscribe('textInput', textInput); 308 eventManager.subscribe('keypress', flushEvent); 309 eventManager.subscribe('focus', synchronizeWindowSelection); 310 311 filters.push(new DetectSafariCompositionError(eventManager)); 312 /** 313 * @param {{destroy:function()}} filter 314 * return {function()} 315 */ 316 function getDestroy(filter) { 317 return filter.destroy; 318 } 319 cleanup = filters.map(getDestroy); 320 321 // Initialize the composition element 322 compositionElement = doc.createElement('span'); 323 compositionElement.setAttribute('id', 'composer'); 324 325 processUpdates = core.Task.createTimeoutTask(synchronizeWindowSelection, 1); 326 cleanup.push(processUpdates.destroy); 327 } 328 329 init(); 330 }; 331 332 /** 333 * @const 334 * @type {!string} 335 */ 336 gui.InputMethodEditor.signalCompositionStart = "input/compositionstart"; 337 338 /** 339 * @const 340 * @type {!string} 341 */ 342 gui.InputMethodEditor.signalCompositionEnd = "input/compositionend"; 343 }()); 344