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