// -*- Mode: js2; tab-width: 2; indent-tabs-mode: nil; js2-basic-offset: 2; js2-skip-preprocessor-directives: t; js2-strict-trailing-comma-warning: nil -*- /* * ***** BEGIN LICENSE BLOCK ***** * Version: MPL 1.1/GPL 2.0/LGPL 2.1 * * The contents of this file are subject to the Mozilla Public License Version * 1.1 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * http://www.mozilla.org/MPL/ * * Software distributed under the License is distributed on an "AS IS" basis, * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License * for the specific language governing rights and limitations under the * License. * * The Original Code is Mozilla Mobile Browser. * * The Initial Developer of the Original Code is * Mozilla Corporation. * Portions created by the Initial Developer are Copyright (C) 2008, 2009 * the Initial Developer. All Rights Reserved. * * Contributor(s): * Stuart Parmenter * Brad Lassey * Mark Finkle * Gavin Sharp * Ben Combee * * Alternatively, the contents of this file may be used under the terms of * either the GNU General Public License Version 2 or later (the "GPL"), or * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), * in which case the provisions of the GPL or the LGPL are applicable instead * of those above. If you wish to allow use of your version of this file only * under the terms of either the GPL or the LGPL, and not to allow others to * use your version of this file under the terms of the MPL, indicate your * decision by deleting the provisions above and replace them with the notice * and other provisions required by the GPL or the LGPL. If you do not delete * the provisions above, a recipient may use your version of this file under * the terms of any one of the MPL, the GPL or the LGPL. * * ***** END LICENSE BLOCK ***** */ function getScrollboxFromElement(elem) { // check element for scrollable interface, if not found check parent until we get to root let scrollbox = null; while (elem.parentNode) { try { if ("scrollBoxObject" in elem && elem.scrollBoxObject) { scrollbox = elem.scrollBoxObject; break; } else if (elem.boxObject) { scrollbox = elem.boxObject.QueryInterface(Ci.nsIScrollBoxObject); break; } } catch (e) { // an exception is OK, we just don't want to propogate it } elem = elem.parentNode; } return scrollbox; } /** * Everything that is registed in _modules gets called with each event that the * InputHandler is registered to listen for. * * When one of the handlers decides it wants to handle the event, it should call * grab() on its owner which will cause it to receive all of the events until it * calls ungrab(). Calling grab will notify the other handlers via a * cancelPending() notification. This tells them to stop what they're doing and * give up hope for being the one to process the events. */ function InputHandler() { /* the list of modules that will handle input */ this._modules = []; /* which module, if any, has all events directed to it */ this._grabbed = null; /* when true, don't process any events */ this._ignoreEvents = false; /* if set, allow a click event to get through */ this._allowNextClick = false; /* used to stop everything if mouse leaves window on desktop */ window.addEventListener("mouseout", this, true); /* these handle dragging of both chrome elements and content */ window.addEventListener("mousedown", this, true); window.addEventListener("mouseup", this, true); window.addEventListener("mousemove", this, true); window.addEventListener("click", this, true); window.addEventListener("mouseout", this, true); let stack = document.getElementById("browser-container"); stack.addEventListener("DOMMouseScroll", this, true); let browserCanvas = document.getElementById("browser-canvas"); browserCanvas.addEventListener("keydown", this, true); browserCanvas.addEventListener("keyup", this, true); let useEarlyMouseMoves = gPrefService.getBoolPref("browser.ui.panning.fixup.mousemove"); this._modules.push(new ChromeInputModule(this, browserCanvas)); this._modules.push(new ContentPanningModule(this, browserCanvas, useEarlyMouseMoves)); this._modules.push(new ContentClickingModule(this)); this._modules.push(new ScrollwheelModule(this)); } InputHandler.prototype = { grab: function grab(obj) { // do nothing if we have a grab and it's the one requested // grab(null) is allowed because of mouseout handling if ((obj == null) || (this._grabbed != obj)) { // only send events to this object this._grabbed = obj; // call cancel on all modules for each(mod in this._modules) { if (mod != obj) mod.cancelPending(); } } }, ungrab: function ungrab(obj) { if (this._grabbed == obj) this._grabbed = null; }, startListening: function startListening() { this._ignoreEvents = false; }, stopListening: function stopListening() { this._ignoreEvents = true; }, allowNextClick: function allowNextClick() { this._allowNextClick = true; }, handleEvent: function handleEvent(aEvent) { if (this._ignoreEvents) return; // to allow supressing most clicks we don't generate ourselves, // filter them here unless handler code has asked us to allow it // through one time if (aEvent.type == "click") { if (this._allowNextClick) { this._allowNextClick = false; } else { aEvent.stopPropagation(); aEvent.preventDefault(); return; } } if (this._grabbed) { this._grabbed.handleEvent(aEvent); } else { for each(mod in this._modules) { mod.handleEvent(aEvent); // if event got grabbed, don't pass to other handlers if (this._grabbed) break; } } } }; /** * Drag Data is used by both chrome and content input modules */ function DragData(owner, dragRadius, dragStartTimeoutLength) { this._owner = owner; this._dragRadius = dragRadius; this.reset(); } DragData.prototype = { reset: function reset() { this.dragging = false; this.sX = null; this.sY = null; this.alreadyLocked = false; this.lockedX = null; this.lockedY = null; this._originX = null; this._originY = null; }, setDragPosition: function setDragPosition(screenX, screenY) { this.sX = screenX; this.sY = screenY; }, setDragStart: function setDragStart(screenX, screenY) { this.setDragPosition(screenX, screenY); this._originX = screenX; this._originY = screenY; this.dragging = true; }, lockMouseMove: function lockMouseMove(sX, sY) { if (this.lockedX !== null) sX = this.lockedX; else if (this.lockedY !== null) sY = this.lockedY; return [sX, sY]; }, lockAxis: function lockAxis(sX, sY) { if (this.alreadyLocked) return this.lockMouseMove(sX, sY); // look at difference from stored coord to lock movement, but only // do it if initial movement is sufficient to detect intent let absX = Math.abs(this.sX - sX); let absY = Math.abs(this.sY - sY); // lock panning if we move more than half of the drag radius and that direction // contributed more than 2/3rd to the radial movement if ((absX > (this._dragRadius / 2)) && ((absX * absX) > (2 * absY * absY))) { this.lockedY = this.sY; sY = this.sY; } else if ((absY > (this._dragRadius / 2)) && ((absY * absY) > (2 * absX * absX))) { this.lockedX = this.sX; sX = this.sX; } this.alreadyLocked = true; return [sX, sY]; }, isPointOutsideRadius: function isPointOutsideRadius(sX, sY) { if (this._originX == undefined) return false; return (Math.pow(sX - this._originX, 2) + Math.pow(sY - this._originY, 2)) > (2 * Math.pow(this._dragRadius, 2)); } }; /** * Panning code for chrome elements */ function ChromeInputModule(owner, browserCanvas) { this._owner = owner; this._browserCanvas = browserCanvas; this._dragData = new DragData(this, 50, 200); this._targetScrollbox = null; this._clickEvents = []; } ChromeInputModule.prototype = { handleEvent: function handleEvent(aEvent) { switch (aEvent.type) { case "mousedown": this._onMouseDown(aEvent); break; case "mousemove": this._onMouseMove(aEvent); break; case "mouseup": this._onMouseUp(aEvent); break; } }, /* If someone else grabs events ahead of us, cancel any pending * timeouts we may have. */ cancelPending: function cancelPending() { this._dragData.reset(); this._targetScrollbox = null; }, // called from DragData.setDragStart only _dragStart: function _dragStart(sX, sY) { let dragData = this._dragData; dragData.setDragStart(sX, sY); [sX, sY] = dragData.lockAxis(sX, sY); }, _dragStop: function _dragStop(sX, sY) { let dragData = this._dragData; [sX, sY] = dragData.lockMouseMove(sX, sY); if (this._targetScrollbox) this._targetScrollbox.scrollBy(dragData.sX - sX, dragData.sY - sY); this._targetScrollbox = null; }, _dragMove: function _dragMove(sX, sY) { let dragData = this._dragData; if (dragData.isPointOutsideRadius(sX, sY)) this._clickEvents = []; [sX, sY] = dragData.lockMouseMove(sX, sY); if (this._targetScrollbox) this._targetScrollbox.scrollBy(dragData.sX - sX, dragData.sY - sY); dragData.setDragPosition(sX, sY); }, _onMouseDown: function _onMouseDown(aEvent) { // exit early for events in the content area if (aEvent.target === this._browserCanvas) { return; } let dragData = this._dragData; this._targetScrollbox = getScrollboxFromElement(aEvent.target); if (!this._targetScrollbox) return; // absorb the event for the scrollable XUL element and make all future events grabbed too this._owner.grab(this); aEvent.stopPropagation(); aEvent.preventDefault(); this._dragStart(aEvent.screenX, aEvent.screenY); this._onMouseMove(aEvent); // treat this as a mouse move too // store away the event for possible sending later if a drag doesn't happen let clickEvent = document.createEvent("MouseEvent"); clickEvent.initMouseEvent(aEvent.type, aEvent.bubbles, aEvent.cancelable, aEvent.view, aEvent.detail, aEvent.screenX, aEvent.screenY, aEvent.clientX, aEvent.clientY, aEvent.ctrlKey, aEvent.altKey, aEvent.shiftKeyArg, aEvent.metaKeyArg, aEvent.button, aEvent.relatedTarget); this._clickEvents.push({event: clickEvent, target: aEvent.target, time: Date.now()}); }, _onMouseUp: function _onMouseUp(aEvent) { // only process if original mousedown was on a scrollable element if (!this._targetScrollbox) { this._owner.allowNextClick(); return; } // keep an eye out for mouseups that didn't start with a mousedown if (!(this._clickEvents.length % 2)) { this._clickEvents = []; } else { let clickEvent = document.createEvent("MouseEvent"); clickEvent.initMouseEvent(aEvent.type, aEvent.bubbles, aEvent.cancelable, aEvent.view, aEvent.detail, aEvent.screenX, aEvent.screenY, aEvent.clientX, aEvent.clientY, aEvent.ctrlKey, aEvent.altKey, aEvent.shiftKeyArg, aEvent.metaKeyArg, aEvent.button, aEvent.relatedTarget); this._clickEvents.push({event: clickEvent, target: aEvent.target, time: Date.now()}); this._sendSingleClick(); this._targetScrollbox = null; } aEvent.stopPropagation(); aEvent.preventDefault(); let dragData = this._dragData; if (dragData.dragging) this._dragStop(aEvent.screenX, aEvent.screenY); dragData.reset(); // be sure to reset the timer this._targetScrollbox = null; this._owner.ungrab(this); }, _onMouseMove: function _onMouseMove(aEvent) { let dragData = this._dragData; // only process if original mousedown was on a scrollable element if (!this._targetScrollbox) return; aEvent.stopPropagation(); aEvent.preventDefault(); let sX = aEvent.screenX; let sY = aEvent.screenY; if (!dragData.sX) dragData.setDragPosition(aEvent.screenX, aEvent.screenY); [sX, sY] = dragData.lockMouseMove(aEvent.screenX, aEvent.screenY); if (!dragData.dragging) return; [sX, sY] = dragData.lockMouseMove(sX, sY); this._dragMove(sX, sY); }, // resend original events with our handler out of the loop _sendSingleClick: function _sendSingleClick() { this._owner.grab(this); this._owner.stopListening(); // send original mouseDown/mouseUps again this._redispatchChromeMouseEvent(this._clickEvents[0].event); this._redispatchChromeMouseEvent(this._clickEvents[1].event); this._owner.startListening(); this._owner.ungrab(this); this._clickEvents = []; }, _redispatchChromeMouseEvent: function _redispatchChromeMouseEvent(aEvent) { if (!(aEvent instanceof MouseEvent)) { Cu.reportError("_redispatchChromeMouseEvent called with a non-mouse event"); return; } // Redispatch the mouse event, ignoring the root scroll frame let cwu = Browser.windowUtils; cwu.sendMouseEvent(aEvent.type, aEvent.clientX, aEvent.clientY, aEvent.button, aEvent.detail, 0, true); } }; /** * Kinetic panning code for content */ function KineticData(owner) { this._owner = owner; this._kineticTimer = null; this.reset(); } KineticData.prototype = { /* const */ _updateInterval : 33, // this would put us at roughly 30fps reset: function reset() { if (this._kineticTimer != null) { this._kineticTimer.cancel(); this._kineticTimer = null; } this.momentumBuffer = []; this._speedX = 0; this._speedY = 0; }, _startKineticTimer: function _startKineticTimer() { let callback = { _self: this, notify: function kineticTimerCallback(timer) { let self = this._self; const decelerationRate = 0.15; // dump(" speeds: " + self._speedX + " " + self._speedY + "\n"); if (self._speedX == 0 && self._speedY == 0) { self.endKinetic(); return; } else { let dx = Math.round(self._speedX * self._updateInterval); let dy = Math.round(self._speedY * self._updateInterval); // dump("dx, dy: " + dx + " " + dy + "\n"); let panned = self._owner._dragBy(dx, dy); if (!panned) { self.endKinetic(); return; } } if (self._speedX < 0) { self._speedX = Math.min(self._speedX + decelerationRate, 0); } else if (self._speedX > 0) { self._speedX = Math.max(self._speedX - decelerationRate, 0); } if (self._speedY < 0) { self._speedY = Math.min(self._speedY + decelerationRate, 0); } else if (self._speedY > 0) { self._speedY = Math.max(self._speedY - decelerationRate, 0); } if (self._speedX == 0 && self._speedY == 0) self.endKinetic(); } }; this._kineticTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); //initialize our timer with updateInterval this._kineticTimer.initWithCallback(callback, this._updateInterval, this._kineticTimer.TYPE_REPEATING_SLACK); }, startKinetic: function startKinetic(sX, sY) { let mb = this.momentumBuffer; let mblen = this.momentumBuffer.length; // If we don't have at least 2 events we can't really do kinetic panning if (mblen < 2) return false; let tempX = 0; let tempY = 0; // build arrays of each movement's speed in pixels/ms let prev = mb[0]; for (let i = 1; i < mblen; i++) { let me = mb[i]; let timeDiff = me.t - prev.t; tempX += (me.sx - prev.sx) / timeDiff; tempY += (me.sy - prev.sy) / timeDiff; prev = me; } // average the speeds out (this could probably be a bit smarter) this._speedX = tempX / mblen; this._speedY = tempY / mblen; // fire off our kinetic timer which will do all the work this._startKineticTimer(); return true; }, endKinetic: function endKinetic() { ws.dragStop(); this.reset(); // Make sure that sidebars don't stay partially open // XXX this should live somewhere else let [leftVis,] = ws.getWidgetVisibility("tabs-container", false); let [rightVis,] = ws.getWidgetVisibility("browser-controls", false); if (leftVis != 0 && leftVis != 1) { let w = document.getElementById("tabs-container").getBoundingClientRect().width; if (leftVis >= 0.6666) ws.panBy(-w, 0, true); else ws.panBy(leftVis * w, 0, true); } else if (rightVis != 0 && rightVis != 1) { let w = document.getElementById("browser-controls").getBoundingClientRect().width; if (rightVis >= 0.6666) ws.panBy(w, 0, true); else ws.panBy(-rightVis * w, 0, true); } // unfreeze the toolbar if we have hide the sidebar let visibleNow = ws.isWidgetVisible("tabs-container") || ws.isWidgetVisible("browser-controls"); if (!visibleNow) ws.unfreeze('toolbar-main') }, addData: function addData(sx, sy) { let mbLength = this.momentumBuffer.length; // avoid adding duplicates which would otherwise slow down the speed let now = Date.now(); if (mbLength > 0) { let mbLast = this.momentumBuffer[mbLength - 1]; if ((mbLast.sx == sx && mbLast.sy == sy) || mbLast.t == now) { return; } } this.momentumBuffer.push({'t': now, 'sx' : sx, 'sy' : sy}); } }; function ContentPanningModule(owner, browserCanvas, useEarlyMouseMoves) { this._owner = owner; this._browserCanvas = browserCanvas; this._dragData = new DragData(this, 50, 200); this._kineticData = new KineticData(this); this._useEarlyMouseMoves = useEarlyMouseMoves; } ContentPanningModule.prototype = { handleEvent: function handleEvent(aEvent) { // exit early for events outside displayed content area if (aEvent.target !== this._browserCanvas) return; switch (aEvent.type) { case "mousedown": this._onMouseDown(aEvent); break; case "mousemove": this._onMouseMove(aEvent); break; case "mouseout": case "mouseup": this._onMouseUp(aEvent); break; } }, /* If someone else grabs events ahead of us, cancel any pending * timeouts we may have. */ cancelPending: function cancelPending() { let dragData = this._dragData; // stop scrolling, pass last coordinate we used this._kineticData.endKinetic(dragData.sX, dragData.sY); this._owner.ungrab(this); dragData.reset(); }, _dragStart: function _dragStart(sX, sY) { let dragData = this._dragData; dragData.setDragStart(sX, sY); [sX, sY] = dragData.lockAxis(sX, sY); ws.dragStart(sX, sY); Browser.canvasBrowser.startPanning(); }, _dragStop: function _dragStop(sX, sY) { let dragData = this._dragData; this._owner.ungrab(this); [sX, sY] = dragData.lockMouseMove(sX, sY); // start kinetic scrolling here for canvas only if (!this._kineticData.startKinetic(sX, sY)) this._kineticData.endKinetic(sX, sY); dragData.reset(); }, _dragBy: function _dragBy(dx, dy) { let panned = ws.dragBy(dx, dy); return panned; }, _dragMove: function _dragMove(sX, sY) { let dragData = this._dragData; [sX, sY] = dragData.lockMouseMove(sX, sY); let panned = ws.dragMove(sX, sY); dragData.setDragPosition(sX, sY); return panned; }, _onMouseDown: function _onMouseDown(aEvent) { let dragData = this._dragData; // if we're in the process of kineticly scrolling, stop and start over if (this._kineticData._kineticTimer != null) { this._kineticData.endKinetic(aEvent.screenX, aEvent.screenY); this._owner.ungrab(this); dragData.reset(); } this._dragStart(aEvent.screenX, aEvent.screenY); this._onMouseMove(aEvent); // treat this as a mouse move too }, _onMouseUp: function _onMouseUp(aEvent) { let dragData = this._dragData; if (dragData.dragging) { this._onMouseMove(aEvent); // treat this as a mouse move, incase our x/y are different this._dragStop(aEvent.screenX, aEvent.screenY); } dragData.reset(); // be sure to reset the timer }, _onMouseMove: function _onMouseMove(aEvent) { // don't do anything if we're in the process of kineticly scrolling if (this._kineticData._kineticTimer != null) return; let dragData = this._dragData; // if we move enough, start a grab to prevent click from getting events if (dragData.isPointOutsideRadius(aEvent.screenX, aEvent.screenY)) this._owner.grab(this); // if we never received a mouseDown, we need to go ahead and set this data if (!dragData.sX) dragData.setDragPosition(aEvent.screenX, aEvent.screenY); let [sX, sY] = dragData.lockMouseMove(aEvent.screenX, aEvent.screenY); // even if we haven't started dragging yet, we should queue up the // mousemoves in case we do start if (this._useEarlyMouseMoves || dragData.dragging) this._kineticData.addData(sX, sY); if (dragData.dragging) this._dragMove(sX, sY); }, }; /** * Mouse click handlers */ function ContentClickingModule(owner) { this._owner = owner; this._clickTimeout = -1; this._events = []; this._zoomedTo = null; } ContentClickingModule.prototype = { handleEvent: function handleEvent(aEvent) { // exit early for events outside displayed content area if (aEvent.target !== document.getElementById("browser-canvas")) return; switch (aEvent.type) { // UI panning events case "mousedown": this._events.push({event: aEvent, time: Date.now()}); // we're waiting for a click if (this._clickTimeout != -1) { // go ahead and stop the timeout so no single click gets // sent, but don't clear clickTimeout here so that mouseUp // handler will treat this as a double click window.clearTimeout(this._clickTimeout); } break; case "mouseup": // keep an eye out for mouseups that didn't start with a mousedown if (!(this._events.length % 2)) { this._reset(); break; } this._events.push({event: aEvent, time: Date.now()}); if (this._clickTimeout == -1) { this._clickTimeout = window.setTimeout(function _clickTimeout(self) { self._sendSingleClick(); }, 400, this); } else { window.clearTimeout(this._clickTimeout); this._clickTimeout = -1; this._sendDoubleClick(); } break; } }, /* If someone else grabs events ahead of us, cancel any pending * timeouts we may have. */ cancelPending: function cancelPending() { this._reset(); }, _reset: function _reset() { if (this._clickTimeout != -1) window.clearTimeout(this._clickTimeout); this._clickTimeout = -1; this._events = []; }, _sendSingleClick: function _sendSingleClick() { this._owner.allowNextClick(); this._owner.grab(this); this._dispatchContentMouseEvent(this._events[0].event); this._dispatchContentMouseEvent(this._events[1].event); this._owner.ungrab(this); this._reset(); }, _sendDoubleClick: function _sendDoubleClick() { this._owner.grab(this); function optimalElementForPoint(cX, cY) { var element = Browser.canvasBrowser.elementFromPoint(cX, cY); return element; } let firstEvent = this._events[0].event; let zoomElement = optimalElementForPoint(firstEvent.clientX, firstEvent.clientY); if (zoomElement) { if (zoomElement != this._zoomedTo) { this._zoomedTo = zoomElement; Browser.canvasBrowser.zoomToElement(zoomElement); } else { this._zoomedTo = null; Browser.canvasBrowser.zoomFromElement(zoomElement); } } this._owner.ungrab(this); this._reset(); }, _dispatchContentMouseEvent: function _dispatchContentMouseEvent(aEvent, aType) { if (!(aEvent instanceof MouseEvent)) { Cu.reportError("_dispatchContentMouseEvent called with a non-mouse event"); return; } let cb = Browser.canvasBrowser; var [x, y] = cb._clientToContentCoords(aEvent.clientX, aEvent.clientY); var cwu = cb.contentDOMWindowUtils; // Redispatch the mouse event, ignoring the root scroll frame cwu.sendMouseEvent(aType || aEvent.type, x, y, aEvent.button || 0, aEvent.detail || 1, 0, true); } }; /** * Scrollwheel zooming handler */ function ScrollwheelModule(owner) { this._owner = owner; } ScrollwheelModule.prototype = { handleEvent: function handleEvent(aEvent) { switch (aEvent.type) { // UI panning events case "DOMMouseScroll": this._owner.grab(this); Browser.canvasBrowser.zoom(aEvent.detail); this._owner.ungrab(this); break; } }, /* If someone else grabs events ahead of us, cancel any pending * timeouts we may have. */ cancelPending: function cancelPending() { } };