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