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