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, Node */
 26 
 27 /**
 28  * Event wiring and management abstraction layer
 29  * This class contains workarounds for various behaviour issues with events cross-browser. Additionally, this
 30  * class provides a mechanism for returning event focus back to the SessionController when it has been lost to
 31  * an external source.
 32  * @constructor
 33  * @implements {core.Destroyable}
 34  * @param {!ops.OdtDocument} odtDocument
 35  */
 36 gui.EventManager = function EventManager(odtDocument) {
 37     "use strict";
 38     var window = /**@type{!Window}*/(runtime.getWindow()),
 39         /**@type{!Object.<string,boolean>}*/
 40         bindToDirectHandler = {
 41             // In Safari 6.0.5 (7536.30.1), Using either attachEvent or addEventListener
 42             // results in the beforecut return value being ignored which prevents cut from being called.
 43             "beforecut": true,
 44             // Epiphany 3.6.1 requires this to allow the paste event to fire
 45             "beforepaste": true,
 46             // Capture long-press events inside the canvas
 47             "longpress": true,
 48             // Capture compound drag events inside the canvas
 49             "drag": true,
 50             // Capture compound dragstop events inside the canvas
 51             "dragstop": true
 52         },
 53         // Events that should be bound to the global window rather than the canvas element
 54         bindToWindow = {
 55             // Capture selections that start outside the canvas element and end within the canvas element
 56             "mousedown": true,
 57             // Capture selections that start inside the canvas element and end outside of the element or even window
 58             "mouseup": true,
 59             // Focus is a non-bubbling event, and we'll usually pass focus to the event trap
 60             "focus": true
 61         },
 62        /**@type{!Object.<string,!CompoundEvent>}*/
 63         compoundEvents = {},
 64        /**@type{!Object.<string,!EventDelegate>}*/
 65         eventDelegates = {},
 66         /**@type{!HTMLInputElement}*/
 67         eventTrap,
 68         canvasElement = /**@type{!HTMLElement}*/(odtDocument.getCanvas().getElement()),
 69         eventManager = this,
 70         /**@type{!Object.<!number, !boolean>}*/
 71         longPressTimers = {},
 72         /**@const*/LONGPRESS_DURATION = 400; // milliseconds
 73 
 74     /**
 75      * Ensures events that may bubble through multiple sources are only handled once.
 76      * @constructor
 77      * @param {!string} eventName Event this delegate is to listen for
 78      */
 79     function EventDelegate(eventName) {
 80         var self = this,
 81             recentEvents = [],
 82             subscribers = new core.EventNotifier([eventName]);
 83 
 84         /**
 85          * @param {!Element|!Window} eventTarget
 86          * @param {!string} eventType
 87          * @param {function(!Event)|function()} eventHandler
 88          * @return {undefined}
 89          */
 90         function listenEvent(eventTarget, eventType, eventHandler) {
 91             var onVariant,
 92                 bound = false;
 93 
 94             onVariant = "on" + eventType;
 95             if (eventTarget.attachEvent) {
 96                 // attachEvent is only supported in Internet Explorer < 11
 97                 eventTarget.attachEvent(onVariant, eventHandler);
 98                 bound = true; // assume it was bound, missing @return in externs.js
 99             }
100             if (!bound && eventTarget.addEventListener) {
101                 eventTarget.addEventListener(eventType, eventHandler, false);
102                 bound = true;
103             }
104 
105             if ((!bound || bindToDirectHandler[eventType]) && eventTarget.hasOwnProperty(onVariant)) {
106                 eventTarget[onVariant] = eventHandler;
107             }
108         }
109 
110         /**
111          * @param {!Element|!Window} eventTarget
112          * @param {!string} eventType
113          * @param {function(!Event)|function()} eventHandler
114          * @return {undefined}
115          */
116         function removeEvent(eventTarget, eventType, eventHandler) {
117             var onVariant = "on" + eventType;
118             if (/**@type{!Element}*/(eventTarget).detachEvent) {
119                 // detachEvent is only supported in Internet Explorer < 11
120                 /**@type{!Element}*/(eventTarget).detachEvent(onVariant, eventHandler);
121             }
122             if (eventTarget.removeEventListener) {
123                 eventTarget.removeEventListener(eventType, eventHandler, false);
124             }
125             if (eventTarget[onVariant] === eventHandler) {
126                 eventTarget[onVariant] = null;
127             }
128         }
129 
130         /**
131          * @param {!Event} e
132          * @return {undefined}
133          */
134         function handleEvent(e) {
135             if (recentEvents.indexOf(e) === -1) {
136                 recentEvents.push(e); // Track this event as already processed by these handlers
137                 if (self.filters.every(function (filter) { return filter(e); })) {
138                     // Yes yes... this is not a spec-compliant event processor... sorry!
139                     try {
140                         subscribers.emit(eventName, e);
141                     } catch(/**@type{!Error}*/err) {
142                         runtime.log("Error occurred while processing " + eventName + ":\n" + err.message + "\n" + err.stack);
143                     }
144                 }
145                 // Reset the processed events list after this tick is complete. The event won't be
146                 // processed by any other sources after this
147                 runtime.setTimeout(function () { recentEvents.splice(recentEvents.indexOf(e), 1); }, 0);
148             }
149         }
150 
151         /**
152          * @type {!Array.<!function(!Event):!boolean>}
153          */
154         this.filters = [];
155 
156         /**
157          * @param {!Function} cb
158          * @return {undefined}
159          */
160         this.subscribe = function (cb) {
161             subscribers.subscribe(eventName, cb);
162         };
163 
164         /**
165          * @param {!Function} cb
166          * @return {undefined}
167          */
168         this.unsubscribe = function (cb) {
169             subscribers.unsubscribe(eventName, cb);
170         };
171 
172         /**
173          * @return {undefined}
174          */
175         this.destroy = function() {
176             removeEvent(window, eventName, handleEvent);
177             removeEvent(eventTrap, eventName, handleEvent);
178             removeEvent(canvasElement, eventName, handleEvent);
179         };
180 
181         function init() {
182             if (bindToWindow[eventName]) {
183                 // Internet explorer will only supply mouse up & down on the window object
184                 // For other browser though, listening to both will cause two events to be processed
185                 listenEvent(window, eventName, handleEvent);
186             }
187             listenEvent(eventTrap, eventName, handleEvent);
188             listenEvent(canvasElement, eventName, handleEvent);
189         }
190         init();
191     }
192 
193     /**
194      * A compound event is an event that is not directly supported
195      * by any browser APIs but which can be representation as a
196      * logical consequence of a combination of several preexisting
197      * events. For example: long press, double tap, pinch, etc.
198      * @constructor
199      * @param {!string} eventName
200      * @param {!Array.<!string>} dependencies,
201      * @param {!function(!Event, !Object, !function(!Object)):undefined} eventProxy
202      */
203     function CompoundEvent(eventName, dependencies, eventProxy) {
204         var /**@type{!Object}*/
205             cachedState = {},
206             subscribers = new core.EventNotifier([eventName]);
207 
208         /**
209          * @param {!Event} event
210          * @return {undefined}
211          */
212         function subscribedProxy(event) {
213             eventProxy(event, cachedState, function (compoundEventInstance) {
214                 compoundEventInstance.type = eventName;
215                 subscribers.emit(eventName, compoundEventInstance);
216             });
217         }
218 
219         /**
220          * @param {!Function} cb
221          * @return {undefined}
222          */
223         this.subscribe = function (cb) {
224             subscribers.subscribe(eventName, cb);
225         };
226 
227         /**
228          * @param {!Function} cb
229          * @return {undefined}
230          */
231         this.unsubscribe = function (cb) {
232             subscribers.unsubscribe(eventName, cb);
233         };
234 
235         /**
236          * @return {undefined}
237          */
238         this.destroy = function () {
239             dependencies.forEach(function (eventName) {
240                 eventManager.unsubscribe(eventName, subscribedProxy);
241             });
242         };
243 
244         function init() {
245             dependencies.forEach(function (eventName) {
246                 eventManager.subscribe(eventName, subscribedProxy);
247             });
248         }
249         init();
250     }
251 
252     /**
253      * @param {!number} timer
254      * @return {undefined}
255      */
256     function clearTimeout(timer) {
257         runtime.clearTimeout(timer);
258         delete longPressTimers[timer];
259     }
260 
261     /**
262      * @param {!Function} fn
263      * @param {!number} duration
264      * @return {!number}
265      */
266     function setTimeout(fn, duration) {
267         var timer = runtime.setTimeout(function () {
268             fn();
269             clearTimeout(timer);
270         }, duration);
271         longPressTimers[timer] = true;
272         return timer;
273     }
274 
275     /**
276      * @param {!Event} e
277      * @return {Node}
278      */
279     function getTarget(e) {
280         // e.srcElement because IE10 likes to be different...
281         return /**@type{Node}*/(e.target) || e.srcElement || null;
282     }
283 
284     /**
285      * A long-press occurs when a finger is placed
286      * against the screen and not lifted or moved
287      * before a specific short duration (400ms seems
288      * approximately the time iOS takes).
289      * @param {!Event} event
290      * @param {!Object} cachedState
291      * @param {!function(!Object):undefined} callback
292      * @return {undefined}
293      */
294     function emitLongPressEvent(event, cachedState, callback) {
295         var touchEvent = /**@type{!TouchEvent}*/(event),
296             fingers = /**@type{!number}*/(touchEvent.touches.length),
297             touch = /**@type{!Touch}*/(touchEvent.touches[0]),
298             timer = /**@type{{timer: !number}}*/(cachedState).timer;
299 
300         if (event.type === 'touchmove' || event.type === 'touchend') {
301             if (timer) {
302                 clearTimeout(timer);
303             }
304         } else if (event.type === 'touchstart') {
305             if (fingers !== 1) {
306                 runtime.clearTimeout(timer);
307             } else {
308                 timer = setTimeout(function () {
309                     callback({
310                         clientX: touch.clientX,
311                         clientY: touch.clientY,
312                         pageX: touch.pageX,
313                         pageY: touch.pageY,
314                         target: getTarget(event),
315                         detail: 1
316                     });
317                 }, LONGPRESS_DURATION);
318             }
319         }
320         cachedState.timer = timer;
321     }
322 
323     /**
324      * Drag events are generated whenever an element with class
325      * 'webodf-draggable' is touched and subsequent finger movements
326      * lie on the same element. This prevents the default
327      * action of touchmove, i.e. usually scrolling.
328      * @param {!Event} event
329      * @param {!Object} cachedState
330      * @param {!function(!Object):undefined} callback
331      * @return {undefined}
332      */
333     function emitDragEvent(event, cachedState, callback) {
334         var touchEvent = /**@type{!TouchEvent}*/(event),
335             fingers = /**@type{!number}*/(touchEvent.touches.length),
336             touch = /**@type{!Touch}*/(touchEvent.touches[0]),
337             target = /**@type{!Element}*/(getTarget(event)),
338             cachedTarget = /**@type{{target: ?Element}}*/(cachedState).target;
339 
340         if (fingers !== 1
341                 || event.type === 'touchend') {
342             cachedTarget = null;
343         } else if (event.type === 'touchstart' && target.getAttribute('class') === 'webodf-draggable') {
344             cachedTarget = target;
345         } else if (event.type === 'touchmove' && cachedTarget) {
346             // Prevent the default action of 'touchmove', i.e. scrolling.
347             event.preventDefault();
348             // Stop propagation, so even if there is no native scroll,
349             // we can block the pan processing in ZoomHelper as well.
350             event.stopPropagation();
351             callback({
352                 clientX: touch.clientX,
353                 clientY: touch.clientY,
354                 pageX: touch.pageX,
355                 pageY: touch.pageY,
356                 target: cachedTarget,
357                 detail: 1
358             });
359         }
360         cachedState.target = cachedTarget;
361     }
362 
363     /**
364      * Drag-stop events are generated whenever an touchend
365      * is preceded by a drag event.
366      * @param {!Event} event
367      * @param {!Object} cachedState
368      * @param {!function(!Object):undefined} callback
369      * @return {undefined}
370      */
371     function emitDragStopEvent(event, cachedState, callback) {
372         var touchEvent = /**@type{!TouchEvent}*/(event),
373             target = /**@type{!Element}*/(getTarget(event)),
374             /**@type{!Touch}*/
375             touch,
376             dragging = /**@type{{dragging: ?boolean}}*/(cachedState).dragging;
377 
378         if (event.type === 'drag') {
379             dragging = true;
380         } else if (event.type === 'touchend' && dragging) {
381             dragging = false;
382             touch = /**@type{!Touch}*/(touchEvent.changedTouches[0]);
383             callback({
384                 clientX: touch.clientX,
385                 clientY: touch.clientY,
386                 pageX: touch.pageX,
387                 pageY: touch.pageY,
388                 target: target,
389                 detail: 1
390             });
391         }
392         cachedState.dragging = dragging;
393     }
394 
395     /**
396      * Adds a class 'webodf-touchEnabled' to the canvas
397      * @return {undefined}
398      */
399     function declareTouchEnabled() {
400         canvasElement.classList.add('webodf-touchEnabled');
401         eventManager.unsubscribe('touchstart', declareTouchEnabled);
402     }
403 
404     /**
405      * @param {!Window} window
406      * @constructor
407      */
408     function WindowScrollState(window) {
409         var x = window.scrollX,
410             y = window.scrollY;
411 
412         /**
413          * Restore the scroll state captured on construction
414          */
415         this.restore = function () {
416             if (window.scrollX !== x || window.scrollY !== y) {
417                 window.scrollTo(x, y);
418             }
419         };
420     }
421 
422     /**
423      * @param {!Element} element
424      * @constructor
425      */
426     function ElementScrollState(element) {
427         var top = element.scrollTop,
428             left = element.scrollLeft;
429 
430         /**
431          * Restore the scroll state captured on construction
432          */
433         this.restore = function () {
434             if (element.scrollTop !== top || element.scrollLeft !== left) {
435                 element.scrollTop = top;
436                 element.scrollLeft = left;
437             }
438         };
439     }
440 
441     /**
442      * Get an event delegate for the requested event name
443      * @param {!string} eventName
444      * @param {!boolean} shouldCreate Create a delegate for the requested event if it doesn't exist
445      * @return {EventDelegate|CompoundEvent}
446      */
447     function getDelegateForEvent(eventName, shouldCreate) {
448         var delegate = eventDelegates[eventName] || compoundEvents[eventName] || null;
449         if (!delegate && shouldCreate) {
450             delegate = eventDelegates[eventName] = new EventDelegate(eventName);
451         }
452         return delegate;
453     }
454 
455     /**
456      * Add an event filter that is able to reject events from being processed
457      * @param {!string} eventName
458      * @param {!function(!Event):!boolean} filter
459      */
460     this.addFilter = function (eventName, filter) {
461         var delegate = getDelegateForEvent(eventName, true);
462         delegate.filters.push(filter);
463     };
464 
465     /**
466      * Remove a registered event filter
467      * @param {!string} eventName
468      * @param {!function(!Event):!boolean} filter
469      */
470     this.removeFilter = function (eventName, filter) {
471         var delegate = getDelegateForEvent(eventName, true),
472             index = delegate.filters.indexOf(filter);
473         if (index !== -1) {
474             delegate.filters.splice(index, 1);
475         }
476     };
477 
478     /**
479      * @param {!string} eventName
480      * @param {function(!Event)|function()} handler
481      * @return {undefined}
482      */
483     function subscribe(eventName, handler) {
484         var delegate = getDelegateForEvent(eventName, true);
485         delegate.subscribe(handler);
486     }
487     this.subscribe = subscribe;
488 
489     /**
490      * @param {!string} eventName
491      * @param {function(!Event)|function()} handler
492      * @return {undefined}
493      */
494     function unsubscribe(eventName, handler) {
495         var delegate = getDelegateForEvent(eventName, false);
496         if (delegate) {
497             delegate.unsubscribe(handler);
498         }
499     }
500     this.unsubscribe = unsubscribe;
501 
502     /**
503      * Returns true if the event manager is currently receiving events
504      * @return {boolean}
505      */
506     function hasFocus() {
507         return odtDocument.getDOMDocument().activeElement === eventTrap;
508     }
509     this.hasFocus = hasFocus;
510 
511     /**
512      * Prevent the event trap from receiving focus
513      * @return {undefined}
514      */
515     function disableTrapSelection() {
516         if (hasFocus()) {
517             // Workaround for a FF bug
518             // If the window selection is in the even trap when it is set non-editable,
519             // further attempts to modify the window selection will crash
520             // https://bugzilla.mozilla.org/show_bug.cgi?id=773137
521             // https://bugzilla.mozilla.org/show_bug.cgi?id=787305
522             eventTrap.blur();
523         }
524         eventTrap.setAttribute("disabled", "true");
525     }
526 
527     /**
528      * Allow the event trap to receive focus
529      * @return {undefined}
530      */
531     function enableTrapSelection() {
532         // A disabled element can't have received focus, so don't need to blur before updating this flag
533         eventTrap.removeAttribute("disabled");
534         // Recovering focus here might cause it to be incorrectly stolen from other elements.
535         // At the time that this patch was written, the primary external controllers of focus are
536         // the SessionController (for mouse related events) and the WebODF editor. Let these restore
537         // focus on their own if desired.
538     }
539 
540     /**
541      * Find the all scrollable ancestor for the specified element
542      * @param {Element} element
543      * @return {!Array.<!WindowScrollState>}
544      */
545     function findScrollableParents(element) {
546         var scrollParents = [];
547         while (element) {
548             // Find the first scrollable parent and track it's current position
549             // This is assumed to be the document scroll pane
550             if (element.scrollWidth > element.clientWidth || element.scrollHeight > element.clientHeight) {
551                 scrollParents.push(new ElementScrollState(element));
552             }
553             element = /**@type {Element}*/(element.parentNode);
554         }
555         scrollParents.push(new WindowScrollState(window));
556         return scrollParents;
557     }
558 
559     /**
560      * Return event focus back to the event manager
561      */
562     function focus() {
563         var scrollParents;
564         if (!hasFocus()) {
565             // http://www.whatwg.org/specs/web-apps/current-work/#focus-management
566             // Passing focus back to an element that did not previously have it will also
567             // cause the element to attempt to recentre back into scroll view
568             scrollParents = findScrollableParents(eventTrap);
569             enableTrapSelection();
570             eventTrap.focus();
571             scrollParents.forEach(function (scrollParent) {
572                 scrollParent.restore();
573             });
574         }
575     }
576     this.focus = focus;
577 
578     /**
579      * Returns the event trap div
580      * @return {!HTMLInputElement}
581      */
582     this.getEventTrap = function () {
583         return eventTrap;
584     };
585 
586     /**
587      * Sets to true when in edit mode; otherwise false
588      * @param {!boolean} editable
589      * @return {undefined}
590      */
591     this.setEditing = function (editable) {
592         var hadFocus = hasFocus();
593         if (hadFocus) {
594             // Toggling flags while the element is in focus
595             // will sometimes stop the browser from allowing the IME to be activated.
596             // Blurring the focus and then restoring ensures the browser re-evaluates
597             // the IME state after the content editable flag has been updated.
598             eventTrap.blur();
599         }
600         if (editable) {
601             eventTrap.removeAttribute("readOnly");
602         } else {
603             eventTrap.setAttribute("readOnly", "true");
604         }
605         if (hadFocus) {
606             focus();
607         }
608     };
609 
610     /**
611       * @param {!function(!Error=)} callback passing an error object in case of error
612       * @return {undefined}
613       */
614     this.destroy = function (callback) {
615         unsubscribe("touchstart", declareTouchEnabled);
616         // Clear all long press timers, just in case
617         Object.keys(longPressTimers).forEach(function (timer) {
618             clearTimeout(parseInt(timer, 10));
619         });
620         longPressTimers.length = 0;
621 
622         Object.keys(compoundEvents).forEach(function (compoundEventName) {
623             compoundEvents[compoundEventName].destroy();
624         });
625         compoundEvents = {};
626 
627         unsubscribe("mousedown", disableTrapSelection);
628         unsubscribe("mouseup", enableTrapSelection);
629         unsubscribe("contextmenu", enableTrapSelection);
630         Object.keys(eventDelegates).forEach(function (eventName) {
631             eventDelegates[eventName].destroy();
632         });
633         eventDelegates = {};
634         // TODO Create warnings for delegates with existing subscriptions. This may indicate leaked event subscribers.
635 
636         eventTrap.parentNode.removeChild(eventTrap);
637         callback();
638     };
639 
640     function init() {
641         var sizerElement = odtDocument.getOdfCanvas().getSizer(),
642             doc = sizerElement.ownerDocument;
643 
644         runtime.assert(Boolean(window), "EventManager requires a window object to operate correctly");
645         eventTrap = /**@type{!HTMLInputElement}*/(doc.createElement("textarea"));
646         eventTrap.id = "eventTrap";
647         // Negative tab index still allows focus, but removes accessibility by keyboard
648         eventTrap.setAttribute("tabindex", "-1");
649         eventTrap.setAttribute("readOnly", "true");
650         eventTrap.setAttribute("rows", "1");
651         sizerElement.appendChild(eventTrap);
652 
653         subscribe("mousedown", disableTrapSelection);
654         subscribe("mouseup", enableTrapSelection);
655         subscribe("contextmenu", enableTrapSelection);
656 
657         compoundEvents.longpress = new CompoundEvent('longpress', ['touchstart', 'touchmove', 'touchend'], emitLongPressEvent);
658         compoundEvents.drag = new CompoundEvent('drag', ['touchstart', 'touchmove', 'touchend'], emitDragEvent);
659         compoundEvents.dragstop = new CompoundEvent('dragstop', ['drag', 'touchend'], emitDragStopEvent);
660 
661         subscribe("touchstart", declareTouchEnabled);
662     }
663     init();
664 };
665