michael@0: // -*- Mode: js2; tab-width: 2; indent-tabs-mode: nil; js2-basic-offset: 2; js2-skip-preprocessor-directives: t; js2-strict-trailing-comma-warning: nil -*- michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: Components.utils.import("resource://gre/modules/Geometry.jsm"); michael@0: michael@0: /* michael@0: * Drag scrolling related constants michael@0: */ michael@0: michael@0: // maximum drag distance in inches while axis locking can still be reverted michael@0: const kAxisLockRevertThreshold = 0.8; michael@0: michael@0: // Same as NS_EVENT_STATE_ACTIVE from mozilla/EventStates.h michael@0: const kStateActive = 0x00000001; michael@0: michael@0: // After a drag begins, kinetic panning is stopped if the drag doesn't become michael@0: // a pan in 300 milliseconds. michael@0: const kStopKineticPanOnDragTimeout = 300; michael@0: michael@0: // Min/max velocity of kinetic panning. This is in pixels/millisecond. michael@0: const kMinVelocity = 0.4; michael@0: const kMaxVelocity = 6; michael@0: michael@0: /* michael@0: * prefs michael@0: */ michael@0: michael@0: // Display rects around selection ranges. Useful in debugging michael@0: // selection problems. michael@0: const kDebugSelectionDisplayPref = "metro.debug.selection.displayRanges"; michael@0: // Dump range rect data to the console. Very useful, but also slows michael@0: // things down a lot. michael@0: const kDebugSelectionDumpPref = "metro.debug.selection.dumpRanges"; michael@0: // Dump message manager event traffic for selection. michael@0: const kDebugSelectionDumpEvents = "metro.debug.selection.dumpEvents"; michael@0: const kAsyncPanZoomEnabled = "layers.async-pan-zoom.enabled" michael@0: michael@0: /** michael@0: * TouchModule michael@0: * michael@0: * Handles all touch-related input such as dragging and tapping. michael@0: * michael@0: * The Fennec chrome DOM tree has elements that are augmented dynamically with michael@0: * custom JS properties that tell the TouchModule they have custom support for michael@0: * either dragging or clicking. These JS properties are JS objects that expose michael@0: * an interface supporting dragging or clicking (though currently we only look michael@0: * to drag scrollable elements). michael@0: * michael@0: * A custom dragger is a JS property that lives on a scrollable DOM element, michael@0: * accessible as myElement.customDragger. The customDragger must support the michael@0: * following interface: (The `scroller' argument is given for convenience, and michael@0: * is the object reference to the element's scrollbox object). michael@0: * michael@0: * dragStart(cX, cY, target, scroller) michael@0: * Signals the beginning of a drag. Coordinates are passed as michael@0: * client coordinates. target is copied from the event. michael@0: * michael@0: * dragStop(dx, dy, scroller) michael@0: * Signals the end of a drag. The dx, dy parameters may be non-zero to michael@0: * indicate one last drag movement. michael@0: * michael@0: * dragMove(dx, dy, scroller, isKinetic) michael@0: * Signals an input attempt to drag by dx, dy. michael@0: * michael@0: * There is a default dragger in case a scrollable element is dragged --- see michael@0: * the defaultDragger prototype property. michael@0: */ michael@0: michael@0: var TouchModule = { michael@0: _debugEvents: false, michael@0: _isCancelled: false, michael@0: _isCancellable: false, michael@0: michael@0: init: function init() { michael@0: this._dragData = new DragData(); michael@0: michael@0: this._dragger = null; michael@0: michael@0: this._targetScrollbox = null; michael@0: this._targetScrollInterface = null; michael@0: michael@0: this._kinetic = new KineticController(this._dragBy.bind(this), michael@0: this._kineticStop.bind(this)); michael@0: michael@0: // capture phase events michael@0: window.addEventListener("CancelTouchSequence", this, true); michael@0: window.addEventListener("keydown", this, true); michael@0: window.addEventListener("MozMouseHittest", this, true); michael@0: michael@0: // bubble phase michael@0: window.addEventListener("contextmenu", this, false); michael@0: window.addEventListener("touchstart", this, false); michael@0: window.addEventListener("touchmove", this, false); michael@0: window.addEventListener("touchend", this, false); michael@0: michael@0: Services.obs.addObserver(this, "Gesture:SingleTap", false); michael@0: Services.obs.addObserver(this, "Gesture:DoubleTap", false); michael@0: }, michael@0: michael@0: /* michael@0: * Events michael@0: */ michael@0: michael@0: handleEvent: function handleEvent(aEvent) { michael@0: switch (aEvent.type) { michael@0: case "contextmenu": michael@0: this._onContextMenu(aEvent); michael@0: break; michael@0: michael@0: case "CancelTouchSequence": michael@0: this.cancelPending(); michael@0: break; michael@0: michael@0: default: { michael@0: if (this._debugEvents) { michael@0: if (aEvent.type != "touchmove") michael@0: Util.dumpLn("TouchModule:", aEvent.type, aEvent.target); michael@0: } michael@0: michael@0: switch (aEvent.type) { michael@0: case "touchstart": michael@0: this._onTouchStart(aEvent); michael@0: break; michael@0: case "touchmove": michael@0: this._onTouchMove(aEvent); michael@0: break; michael@0: case "touchend": michael@0: this._onTouchEnd(aEvent); michael@0: break; michael@0: case "keydown": michael@0: this._handleKeyDown(aEvent); michael@0: break; michael@0: case "MozMouseHittest": michael@0: // Used by widget to hit test chrome vs content. Make sure the XUl scrollbars michael@0: // are counted as "chrome". Since the XUL scrollbars have sub-elements we walk michael@0: // the parent chain to ensure we catch all of those as well. michael@0: let onScrollbar = false; michael@0: for (let node = aEvent.originalTarget; node instanceof XULElement; node = node.parentNode) { michael@0: if (node.tagName == 'scrollbar') { michael@0: onScrollbar = true; michael@0: break; michael@0: } michael@0: } michael@0: if (onScrollbar || aEvent.target.ownerDocument == document) { michael@0: aEvent.preventDefault(); michael@0: } michael@0: aEvent.stopPropagation(); michael@0: break; michael@0: } michael@0: } michael@0: } michael@0: }, michael@0: michael@0: _handleKeyDown: function _handleKeyDown(aEvent) { michael@0: const TABKEY = 9; michael@0: if (aEvent.keyCode == TABKEY && !InputSourceHelper.isPrecise) { michael@0: if (Util.isEditable(aEvent.target) && michael@0: aEvent.target.selectionStart != aEvent.target.selectionEnd) { michael@0: SelectionHelperUI.closeEditSession(false); michael@0: } michael@0: setTimeout(function() { michael@0: let element = Browser.selectedBrowser.contentDocument.activeElement; michael@0: // We only want to attach monocles if we have an input, text area, michael@0: // there is selection, and the target element changed. michael@0: // Sometimes the target element won't change even though selection is michael@0: // cleared because of focus outside the browser. michael@0: if (Util.isEditable(element) && michael@0: !SelectionHelperUI.isActive && michael@0: element.selectionStart != element.selectionEnd && michael@0: // not e10s friendly michael@0: aEvent.target != element) { michael@0: let rect = element.getBoundingClientRect(); michael@0: SelectionHelperUI.attachEditSession(Browser.selectedBrowser, michael@0: rect.left + rect.width / 2, michael@0: rect.top + rect.height / 2); michael@0: } michael@0: }, 50); michael@0: } michael@0: }, michael@0: michael@0: observe: function BrowserUI_observe(aSubject, aTopic, aData) { michael@0: switch (aTopic) { michael@0: case "Gesture:SingleTap": michael@0: case "Gesture:DoubleTap": michael@0: Browser.selectedBrowser.messageManager.sendAsyncMessage(aTopic, JSON.parse(aData)); michael@0: break; michael@0: } michael@0: }, michael@0: michael@0: michael@0: sample: function sample(aTimeStamp) { michael@0: this._waitingForPaint = false; michael@0: }, michael@0: michael@0: /** michael@0: * This gets invoked by the input handler if another module grabs. We should michael@0: * reset our state or something here. This is probably doing the wrong thing michael@0: * in its current form. michael@0: */ michael@0: cancelPending: function cancelPending() { michael@0: this._doDragStop(); michael@0: michael@0: // Kinetic panning may have already been active or drag stop above may have michael@0: // made kinetic panning active. michael@0: this._kinetic.end(); michael@0: michael@0: this._targetScrollbox = null; michael@0: this._targetScrollInterface = null; michael@0: }, michael@0: michael@0: _onContextMenu: function _onContextMenu(aEvent) { michael@0: // bug 598965 - chrome UI should stop to be pannable once the michael@0: // context menu has appeared. michael@0: if (ContextMenuUI.popupState) { michael@0: this.cancelPending(); michael@0: } michael@0: }, michael@0: michael@0: /** Begin possible pan and send tap down event. */ michael@0: _onTouchStart: function _onTouchStart(aEvent) { michael@0: if (aEvent.touches.length > 1) michael@0: return; michael@0: michael@0: this._isCancelled = false; michael@0: this._isCancellable = true; michael@0: michael@0: if (aEvent.defaultPrevented) { michael@0: this._isCancelled = true; michael@0: return; michael@0: } michael@0: michael@0: let dragData = this._dragData; michael@0: if (dragData.dragging) { michael@0: // Somehow a mouse up was missed. michael@0: this._doDragStop(); michael@0: } michael@0: dragData.reset(); michael@0: this.dX = 0; michael@0: this.dY = 0; michael@0: michael@0: // walk up the DOM tree in search of nearest scrollable ancestor. nulls are michael@0: // returned if none found. michael@0: let [targetScrollbox, targetScrollInterface, dragger] michael@0: = ScrollUtils.getScrollboxFromElement(aEvent.originalTarget); michael@0: michael@0: // stop kinetic panning if targetScrollbox has changed michael@0: if (this._kinetic.isActive() && this._dragger != dragger) michael@0: this._kinetic.end(); michael@0: michael@0: this._targetScrollbox = targetScrollInterface ? targetScrollInterface.element : targetScrollbox; michael@0: this._targetScrollInterface = targetScrollInterface; michael@0: michael@0: if (!this._targetScrollbox) { michael@0: return; michael@0: } michael@0: michael@0: // Don't allow kinetic panning if APZC is enabled and the pan element is the deck michael@0: let deck = document.getElementById("browsers"); michael@0: if (Services.prefs.getBoolPref(kAsyncPanZoomEnabled) && michael@0: this._targetScrollbox == deck) { michael@0: return; michael@0: } michael@0: michael@0: // XXX shouldn't dragger always be valid here? michael@0: if (dragger) { michael@0: let draggable = dragger.isDraggable(targetScrollbox, targetScrollInterface); michael@0: dragData.locked = !draggable.x || !draggable.y; michael@0: if (draggable.x || draggable.y) { michael@0: this._dragger = dragger; michael@0: if (dragger.freeDrag) michael@0: dragData.alwaysFreeDrag = dragger.freeDrag(); michael@0: this._doDragStart(aEvent, draggable); michael@0: } michael@0: } michael@0: }, michael@0: michael@0: /** Send tap up event and any necessary full taps. */ michael@0: _onTouchEnd: function _onTouchEnd(aEvent) { michael@0: if (aEvent.touches.length > 0 || this._isCancelled || !this._targetScrollbox) michael@0: return; michael@0: michael@0: // onMouseMove will not record the delta change if we are waiting for a michael@0: // paint. Since this is the last input for this drag, we override the flag. michael@0: this._waitingForPaint = false; michael@0: this._onTouchMove(aEvent); michael@0: michael@0: let dragData = this._dragData; michael@0: this._doDragStop(); michael@0: }, michael@0: michael@0: /** michael@0: * If we're in a drag, do what we have to do to drag on. michael@0: */ michael@0: _onTouchMove: function _onTouchMove(aEvent) { michael@0: if (aEvent.touches.length > 1) michael@0: return; michael@0: michael@0: if (this._isCancellable) { michael@0: // only the first touchmove is cancellable. michael@0: this._isCancellable = false; michael@0: if (aEvent.defaultPrevented) { michael@0: this._isCancelled = true; michael@0: } michael@0: } michael@0: michael@0: if (this._isCancelled) michael@0: return; michael@0: michael@0: let touch = aEvent.changedTouches[0]; michael@0: if (!this._targetScrollbox) { michael@0: return; michael@0: } michael@0: michael@0: let dragData = this._dragData; michael@0: michael@0: if (dragData.dragging) { michael@0: let oldIsPan = dragData.isPan(); michael@0: dragData.setDragPosition(touch.screenX, touch.screenY); michael@0: dragData.setMousePosition(touch); michael@0: michael@0: // Kinetic panning is sensitive to time. It is more stable if it receives michael@0: // the mousemove events as they come. For dragging though, we only want michael@0: // to call _dragBy if we aren't waiting for a paint (so we don't spam the michael@0: // main browser loop with a bunch of redundant paints). michael@0: // michael@0: // Here, we feed kinetic panning drag differences for mouse events as michael@0: // come; for dragging, we build up a drag buffer in this.dX/this.dY and michael@0: // release it when we are ready to paint. michael@0: // michael@0: let [sX, sY] = dragData.panPosition(); michael@0: this.dX += dragData.prevPanX - sX; michael@0: this.dY += dragData.prevPanY - sY; michael@0: michael@0: if (dragData.isPan()) { michael@0: // Only pan when mouse event isn't part of a click. Prevent jittering on tap. michael@0: this._kinetic.addData(sX - dragData.prevPanX, sY - dragData.prevPanY); michael@0: michael@0: // dragBy will reset dX and dY values to 0 michael@0: this._dragBy(this.dX, this.dY); michael@0: michael@0: // Let everyone know when mousemove begins a pan michael@0: if (!oldIsPan && dragData.isPan()) { michael@0: let event = document.createEvent("Events"); michael@0: event.initEvent("PanBegin", true, false); michael@0: this._targetScrollbox.dispatchEvent(event); michael@0: michael@0: Browser.selectedBrowser.messageManager.sendAsyncMessage("Browser:PanBegin", {}); michael@0: } michael@0: } michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Inform our dragger of a dragStart. michael@0: */ michael@0: _doDragStart: function _doDragStart(aEvent, aDraggable) { michael@0: let touch = aEvent.changedTouches[0]; michael@0: let dragData = this._dragData; michael@0: dragData.setDragStart(touch.screenX, touch.screenY, aDraggable); michael@0: this._kinetic.addData(0, 0); michael@0: this._dragStartTime = Date.now(); michael@0: if (!this._kinetic.isActive()) { michael@0: this._dragger.dragStart(touch.clientX, touch.clientY, touch.target, this._targetScrollInterface); michael@0: } michael@0: }, michael@0: michael@0: /** Finish a drag. */ michael@0: _doDragStop: function _doDragStop() { michael@0: let dragData = this._dragData; michael@0: if (!dragData.dragging) michael@0: return; michael@0: michael@0: dragData.endDrag(); michael@0: michael@0: // Note: it is possible for kinetic scrolling to be active from a michael@0: // mousedown/mouseup event previous to this one. In this case, we michael@0: // want the kinetic panner to tell our drag interface to stop. michael@0: michael@0: if (dragData.isPan()) { michael@0: if (Date.now() - this._dragStartTime > kStopKineticPanOnDragTimeout) michael@0: this._kinetic._velocity.set(0, 0); michael@0: michael@0: // Start kinetic pan if we aren't using async pan zoom or the scroll michael@0: // element is not browsers. michael@0: let deck = document.getElementById("browsers"); michael@0: if (!Services.prefs.getBoolPref(kAsyncPanZoomEnabled) || michael@0: this._targetScrollbox != deck) { michael@0: this._kinetic.start(); michael@0: } michael@0: } else { michael@0: this._kinetic.end(); michael@0: if (this._dragger) michael@0: this._dragger.dragStop(0, 0, this._targetScrollInterface); michael@0: this._dragger = null; michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Used by _onTouchMove() above and by KineticController's timer to do the michael@0: * actual dragMove signalling to the dragger. We'd put this in _onTouchMove() michael@0: * but then KineticController would be adding to its own data as it signals michael@0: * the dragger of dragMove()s. michael@0: */ michael@0: _dragBy: function _dragBy(dX, dY, aIsKinetic) { michael@0: let dragged = true; michael@0: let dragData = this._dragData; michael@0: if (!this._waitingForPaint || aIsKinetic) { michael@0: let dragData = this._dragData; michael@0: dragged = this._dragger.dragMove(dX, dY, this._targetScrollInterface, aIsKinetic, michael@0: dragData._mouseX, dragData._mouseY); michael@0: if (dragged && !this._waitingForPaint) { michael@0: this._waitingForPaint = true; michael@0: mozRequestAnimationFrame(this); michael@0: } michael@0: this.dX = 0; michael@0: this.dY = 0; michael@0: } michael@0: if (!dragData.isPan()) michael@0: this._kinetic.pause(); michael@0: michael@0: return dragged; michael@0: }, michael@0: michael@0: /** Callback for kinetic scroller. */ michael@0: _kineticStop: function _kineticStop() { michael@0: // Kinetic panning could finish while user is panning, so don't finish michael@0: // the pan just yet. michael@0: let dragData = this._dragData; michael@0: if (!dragData.dragging) { michael@0: if (this._dragger) michael@0: this._dragger.dragStop(0, 0, this._targetScrollInterface); michael@0: this._dragger = null; michael@0: michael@0: let event = document.createEvent("Events"); michael@0: event.initEvent("PanFinished", true, false); michael@0: this._targetScrollbox.dispatchEvent(event); michael@0: } michael@0: }, michael@0: michael@0: toString: function toString() { michael@0: return '[TouchModule] {' michael@0: + '\n\tdragData=' + this._dragData + ', ' michael@0: + 'dragger=' + this._dragger + ', ' michael@0: + '\n\ttargetScroller=' + this._targetScrollInterface + '}'; michael@0: }, michael@0: }; michael@0: michael@0: var ScrollUtils = { michael@0: // threshold in pixels for sensing a tap as opposed to a pan michael@0: get tapRadius() { michael@0: let dpi = Util.displayDPI; michael@0: delete this.tapRadius; michael@0: return this.tapRadius = Services.prefs.getIntPref("ui.dragThresholdX") / 240 * dpi; michael@0: }, michael@0: michael@0: /** michael@0: * Walk up (parentward) the DOM tree from elem in search of a scrollable element. michael@0: * Return the element and its scroll interface if one is found, two nulls otherwise. michael@0: * michael@0: * This function will cache the pointer to the scroll interface on the element itself, michael@0: * so it is safe to call it many times without incurring the same XPConnect overhead michael@0: * as in the initial call. michael@0: */ michael@0: getScrollboxFromElement: function getScrollboxFromElement(elem) { michael@0: let scrollbox = null; michael@0: let qinterface = null; michael@0: michael@0: // if element is content or the startui page, get the browser scroll interface michael@0: if (elem.ownerDocument == Browser.selectedBrowser.contentDocument) { michael@0: elem = Browser.selectedBrowser; michael@0: } michael@0: for (; elem; elem = elem.parentNode) { michael@0: try { michael@0: if (elem.anonScrollBox) { michael@0: scrollbox = elem.anonScrollBox; michael@0: qinterface = scrollbox.boxObject.QueryInterface(Ci.nsIScrollBoxObject); michael@0: } else if (elem.scrollBoxObject) { michael@0: scrollbox = elem; michael@0: qinterface = elem.scrollBoxObject; michael@0: break; michael@0: } else if (elem.customDragger) { michael@0: scrollbox = elem; michael@0: break; michael@0: } else if (elem.boxObject) { michael@0: let qi = (elem._cachedSBO) ? elem._cachedSBO michael@0: : elem.boxObject.QueryInterface(Ci.nsIScrollBoxObject); michael@0: if (qi) { michael@0: scrollbox = elem; michael@0: scrollbox._cachedSBO = qinterface = qi; michael@0: break; michael@0: } michael@0: } michael@0: } catch (e) { /* we aren't here to deal with your exceptions, we'll just keep michael@0: traversing until we find something more well-behaved, as we michael@0: prefer default behaviour to whiny scrollers. */ } michael@0: } michael@0: return [scrollbox, qinterface, (scrollbox ? (scrollbox.customDragger || this._defaultDragger) : null)]; michael@0: }, michael@0: michael@0: /** Determine if the distance moved can be considered a pan */ michael@0: isPan: function isPan(aPoint, aPoint2) { michael@0: return (Math.abs(aPoint.x - aPoint2.x) > this.tapRadius || michael@0: Math.abs(aPoint.y - aPoint2.y) > this.tapRadius); michael@0: }, michael@0: michael@0: /** michael@0: * The default dragger object used by TouchModule when dragging a scrollable michael@0: * element that provides no customDragger. Simply performs the expected michael@0: * regular scrollBy calls on the scroller. michael@0: */ michael@0: _defaultDragger: { michael@0: isDraggable: function isDraggable(target, scroller) { michael@0: let sX = {}, sY = {}, michael@0: pX = {}, pY = {}; michael@0: scroller.getPosition(pX, pY); michael@0: scroller.getScrolledSize(sX, sY); michael@0: let rect = target.getBoundingClientRect(); michael@0: return { x: (sX.value > rect.width || pX.value != 0), michael@0: y: (sY.value > rect.height || pY.value != 0) }; michael@0: }, michael@0: michael@0: dragStart: function dragStart(cx, cy, target, scroller) { michael@0: scroller.element.addEventListener("PanBegin", this._showScrollbars, false); michael@0: }, michael@0: michael@0: dragStop: function dragStop(dx, dy, scroller) { michael@0: scroller.element.removeEventListener("PanBegin", this._showScrollbars, false); michael@0: return this.dragMove(dx, dy, scroller); michael@0: }, michael@0: michael@0: dragMove: function dragMove(dx, dy, scroller) { michael@0: if (scroller.getPosition) { michael@0: try { michael@0: let oldX = {}, oldY = {}; michael@0: scroller.getPosition(oldX, oldY); michael@0: michael@0: scroller.scrollBy(dx, dy); michael@0: michael@0: let newX = {}, newY = {}; michael@0: scroller.getPosition(newX, newY); michael@0: michael@0: return (newX.value != oldX.value) || (newY.value != oldY.value); michael@0: michael@0: } catch (e) { /* we have no time for whiny scrollers! */ } michael@0: } michael@0: michael@0: return false; michael@0: }, michael@0: michael@0: _showScrollbars: function _showScrollbars(aEvent) { michael@0: let scrollbox = aEvent.target; michael@0: scrollbox.setAttribute("panning", "true"); michael@0: michael@0: let hideScrollbars = function() { michael@0: scrollbox.removeEventListener("PanFinished", hideScrollbars, false); michael@0: scrollbox.removeEventListener("CancelTouchSequence", hideScrollbars, false); michael@0: scrollbox.removeAttribute("panning"); michael@0: } michael@0: michael@0: // Wait for panning to be completely finished before removing scrollbars michael@0: scrollbox.addEventListener("PanFinished", hideScrollbars, false); michael@0: scrollbox.addEventListener("CancelTouchSequence", hideScrollbars, false); michael@0: } michael@0: } michael@0: }; michael@0: michael@0: /** michael@0: * DragData handles processing drags on the screen, handling both michael@0: * locking of movement on one axis, and click detection. michael@0: */ michael@0: function DragData() { michael@0: this._domUtils = Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils); michael@0: this._lockRevertThreshold = Util.displayDPI * kAxisLockRevertThreshold; michael@0: this.reset(); michael@0: }; michael@0: michael@0: DragData.prototype = { michael@0: reset: function reset() { michael@0: this.dragging = false; michael@0: this.sX = null; michael@0: this.sY = null; michael@0: this.locked = false; michael@0: this.stayLocked = false; michael@0: this.alwaysFreeDrag = false; michael@0: this.lockedX = null; michael@0: this.lockedY = null; michael@0: this._originX = null; michael@0: this._originY = null; michael@0: this.prevPanX = null; michael@0: this.prevPanY = null; michael@0: this._isPan = false; michael@0: }, michael@0: michael@0: /** Depending on drag data, locks sX,sY to X-axis or Y-axis of start position. */ michael@0: _lockAxis: function _lockAxis(sX, sY) { michael@0: if (this.locked) { michael@0: if (this.lockedX !== null) michael@0: sX = this.lockedX; michael@0: else if (this.lockedY !== null) michael@0: sY = this.lockedY; michael@0: return [sX, sY]; michael@0: } michael@0: else { michael@0: return [this._originX, this._originY]; michael@0: } michael@0: }, michael@0: michael@0: setMousePosition: function setMousePosition(aEvent) { michael@0: this._mouseX = aEvent.clientX; michael@0: this._mouseY = aEvent.clientY; michael@0: }, michael@0: michael@0: setDragPosition: function setDragPosition(sX, sY) { michael@0: // Check if drag is now a pan. michael@0: if (!this._isPan) { michael@0: this._isPan = ScrollUtils.isPan(new Point(this._originX, this._originY), new Point(sX, sY)); michael@0: if (this._isPan) { michael@0: this._resetActive(); michael@0: } michael@0: } michael@0: michael@0: // If now a pan, mark previous position where panning was. michael@0: if (this._isPan) { michael@0: let absX = Math.abs(this._originX - sX); michael@0: let absY = Math.abs(this._originY - sY); michael@0: michael@0: if (absX > this._lockRevertThreshold || absY > this._lockRevertThreshold) michael@0: this.stayLocked = true; michael@0: michael@0: // After the first lock, see if locking decision should be reverted. michael@0: if (!this.stayLocked) { michael@0: if (this.lockedX && absX > 3 * absY) michael@0: this.lockedX = null; michael@0: else if (this.lockedY && absY > 3 * absX) michael@0: this.lockedY = null; michael@0: } michael@0: michael@0: if (!this.locked) { michael@0: // look at difference from origin coord to lock movement, but only michael@0: // do it if initial movement is sufficient to detect intent michael@0: michael@0: // divide possibilty space into eight parts. Diagonals will allow michael@0: // free movement, while moving towards a cardinal will lock that michael@0: // axis. We pick a direction if you move more than twice as far michael@0: // on one axis than another, which should be an angle of about 30 michael@0: // degrees from the axis michael@0: michael@0: if (absX > 2.5 * absY) michael@0: this.lockedY = sY; michael@0: else if (absY > absX) michael@0: this.lockedX = sX; michael@0: michael@0: this.locked = true; michael@0: } michael@0: } michael@0: michael@0: // Never lock if the dragger requests it michael@0: if (this.alwaysFreeDrag) { michael@0: this.lockedY = null; michael@0: this.lockedX = null; michael@0: } michael@0: michael@0: // After pan lock, figure out previous panning position. Base it on last drag michael@0: // position so there isn't a jump in panning. michael@0: let [prevX, prevY] = this._lockAxis(this.sX, this.sY); michael@0: this.prevPanX = prevX; michael@0: this.prevPanY = prevY; michael@0: michael@0: this.sX = sX; michael@0: this.sY = sY; michael@0: }, michael@0: michael@0: setDragStart: function setDragStart(screenX, screenY, aDraggable) { michael@0: this.sX = this._originX = screenX; michael@0: this.sY = this._originY = screenY; michael@0: this.dragging = true; michael@0: michael@0: // If the target area is pannable only in one direction lock it early michael@0: // on the right axis michael@0: this.lockedX = !aDraggable.x ? screenX : null; michael@0: this.lockedY = !aDraggable.y ? screenY : null; michael@0: this.stayLocked = this.lockedX || this.lockedY; michael@0: this.locked = this.stayLocked; michael@0: }, michael@0: michael@0: endDrag: function endDrag() { michael@0: this._resetActive(); michael@0: this.dragging = false; michael@0: }, michael@0: michael@0: /** Returns true if drag should pan scrollboxes.*/ michael@0: isPan: function isPan() { michael@0: return this._isPan; michael@0: }, michael@0: michael@0: /** Return true if drag should be parsed as a click. */ michael@0: isClick: function isClick() { michael@0: return !this._isPan; michael@0: }, michael@0: michael@0: /** michael@0: * Returns the screen position for a pan. This factors in axis locking. michael@0: * @return Array of screen X and Y coordinates michael@0: */ michael@0: panPosition: function panPosition() { michael@0: return this._lockAxis(this.sX, this.sY); michael@0: }, michael@0: michael@0: /** dismiss the active state of the pan element */ michael@0: _resetActive: function _resetActive() { michael@0: let target = document.documentElement; michael@0: // If the target is active, toggle (turn off) the active flag. Otherwise do nothing. michael@0: if (this._domUtils.getContentState(target) & kStateActive) michael@0: this._domUtils.setContentState(target, kStateActive); michael@0: }, michael@0: michael@0: toString: function toString() { michael@0: return '[DragData] { sX,sY=' + this.sX + ',' + this.sY + ', dragging=' + this.dragging + ' }'; michael@0: } michael@0: }; michael@0: michael@0: michael@0: /** michael@0: * KineticController - a class to take drag position data and use it michael@0: * to do kinetic panning of a scrollable object. michael@0: * michael@0: * aPanBy is a function that will be called with the dx and dy michael@0: * generated by the kinetic algorithm. It should return true if the michael@0: * object was panned, false if there was no movement. michael@0: * michael@0: * There are two complicated things done here. One is calculating the michael@0: * initial velocity of the movement based on user input. Two is michael@0: * calculating the distance to move every frame. michael@0: */ michael@0: function KineticController(aPanBy, aEndCallback) { michael@0: this._panBy = aPanBy; michael@0: this._beforeEnd = aEndCallback; michael@0: michael@0: // These are used to calculate the position of the scroll panes during kinetic panning. Think of michael@0: // these points as vectors that are added together and multiplied by scalars. michael@0: this._position = new Point(0, 0); michael@0: this._velocity = new Point(0, 0); michael@0: this._acceleration = new Point(0, 0); michael@0: this._time = 0; michael@0: this._timeStart = 0; michael@0: michael@0: // How often do we change the position of the scroll pane? Too often and panning may jerk near michael@0: // the end. Too little and panning will be choppy. In milliseconds. michael@0: this._updateInterval = Services.prefs.getIntPref("browser.ui.kinetic.updateInterval"); michael@0: // Constants that affect the "friction" of the scroll pane. michael@0: this._exponentialC = Services.prefs.getIntPref("browser.ui.kinetic.exponentialC"); michael@0: this._polynomialC = Services.prefs.getIntPref("browser.ui.kinetic.polynomialC") / 1000000; michael@0: // Number of milliseconds that can contain a swipe. Movements earlier than this are disregarded. michael@0: this._swipeLength = Services.prefs.getIntPref("browser.ui.kinetic.swipeLength"); michael@0: michael@0: this._reset(); michael@0: } michael@0: michael@0: KineticController.prototype = { michael@0: _reset: function _reset() { michael@0: this._active = false; michael@0: this._paused = false; michael@0: this.momentumBuffer = []; michael@0: this._velocity.set(0, 0); michael@0: }, michael@0: michael@0: isActive: function isActive() { michael@0: return this._active; michael@0: }, michael@0: michael@0: _startTimer: function _startTimer() { michael@0: let self = this; michael@0: michael@0: let lastp = this._position; // track last position vector because pan takes deltas michael@0: let v0 = this._velocity; // initial velocity michael@0: let a = this._acceleration; // acceleration michael@0: let c = this._exponentialC; michael@0: let p = new Point(0, 0); michael@0: let dx, dy, t, realt; michael@0: michael@0: function calcP(v0, a, t) { michael@0: // Important traits for this function: michael@0: // p(t=0) is 0 michael@0: // p'(t=0) is v0 michael@0: // michael@0: // We use exponential to get a smoother stop, but by itself exponential michael@0: // is too smooth at the end. Adding a polynomial with the appropriate michael@0: // weight helps to balance michael@0: return v0 * Math.exp(-t / c) * -c + a * t * t + v0 * c; michael@0: } michael@0: michael@0: this._calcV = function(v0, a, t) { michael@0: return v0 * Math.exp(-t / c) + 2 * a * t; michael@0: } michael@0: michael@0: let callback = { michael@0: sample: function kineticHandleEvent(timeStamp) { michael@0: // Someone called end() on us between timer intervals michael@0: // or we are paused. michael@0: if (!self.isActive() || self._paused) michael@0: return; michael@0: michael@0: // To make animation end fast enough but to keep smoothness, average the ideal michael@0: // time frame (smooth animation) with the actual time lapse (end fast enough). michael@0: // Animation will never take longer than 2 times the ideal length of time. michael@0: realt = timeStamp - self._initialTime; michael@0: self._time += self._updateInterval; michael@0: t = (self._time + realt) / 2; michael@0: michael@0: // Calculate new position. michael@0: p.x = calcP(v0.x, a.x, t); michael@0: p.y = calcP(v0.y, a.y, t); michael@0: dx = Math.round(p.x - lastp.x); michael@0: dy = Math.round(p.y - lastp.y); michael@0: michael@0: // Test to see if movement is finished for each component. michael@0: if (dx * a.x > 0) { michael@0: dx = 0; michael@0: lastp.x = 0; michael@0: v0.x = 0; michael@0: a.x = 0; michael@0: } michael@0: // Symmetric to above case. michael@0: if (dy * a.y > 0) { michael@0: dy = 0; michael@0: lastp.y = 0; michael@0: v0.y = 0; michael@0: a.y = 0; michael@0: } michael@0: michael@0: if (v0.x == 0 && v0.y == 0) { michael@0: self.end(); michael@0: } else { michael@0: let panStop = false; michael@0: if (dx != 0 || dy != 0) { michael@0: try { panStop = !self._panBy(-dx, -dy, true); } catch (e) {} michael@0: lastp.add(dx, dy); michael@0: } michael@0: michael@0: if (panStop) michael@0: self.end(); michael@0: else michael@0: mozRequestAnimationFrame(this); michael@0: } michael@0: } michael@0: }; michael@0: michael@0: this._active = true; michael@0: this._paused = false; michael@0: mozRequestAnimationFrame(callback); michael@0: }, michael@0: michael@0: start: function start() { michael@0: function sign(x) { michael@0: return x ? ((x > 0) ? 1 : -1) : 0; michael@0: } michael@0: michael@0: function clampFromZero(x, closerToZero, furtherFromZero) { michael@0: if (x >= 0) michael@0: return Math.max(closerToZero, Math.min(furtherFromZero, x)); michael@0: return Math.min(-closerToZero, Math.max(-furtherFromZero, x)); michael@0: } michael@0: michael@0: let mb = this.momentumBuffer; michael@0: let mblen = this.momentumBuffer.length; michael@0: michael@0: let lastTime = mb[mblen - 1].t; michael@0: let distanceX = 0; michael@0: let distanceY = 0; michael@0: let swipeLength = this._swipeLength; michael@0: michael@0: // determine speed based on recorded input michael@0: let me; michael@0: for (let i = 0; i < mblen; i++) { michael@0: me = mb[i]; michael@0: if (lastTime - me.t < swipeLength) { michael@0: distanceX += me.dx; michael@0: distanceY += me.dy; michael@0: } michael@0: } michael@0: michael@0: let currentVelocityX = 0; michael@0: let currentVelocityY = 0; michael@0: michael@0: if (this.isActive()) { michael@0: // If active, then we expect this._calcV to be defined. michael@0: let currentTime = Date.now() - this._initialTime; michael@0: currentVelocityX = Util.clamp(this._calcV(this._velocity.x, this._acceleration.x, currentTime), -kMaxVelocity, kMaxVelocity); michael@0: currentVelocityY = Util.clamp(this._calcV(this._velocity.y, this._acceleration.y, currentTime), -kMaxVelocity, kMaxVelocity); michael@0: } michael@0: michael@0: if (currentVelocityX * this._velocity.x <= 0) michael@0: currentVelocityX = 0; michael@0: if (currentVelocityY * this._velocity.y <= 0) michael@0: currentVelocityY = 0; michael@0: michael@0: let swipeTime = Math.min(swipeLength, lastTime - mb[0].t); michael@0: this._velocity.x = clampFromZero((distanceX / swipeTime) + currentVelocityX, Math.abs(currentVelocityX), kMaxVelocity); michael@0: this._velocity.y = clampFromZero((distanceY / swipeTime) + currentVelocityY, Math.abs(currentVelocityY), kMaxVelocity); michael@0: michael@0: if (Math.abs(this._velocity.x) < kMinVelocity) michael@0: this._velocity.x = 0; michael@0: if (Math.abs(this._velocity.y) < kMinVelocity) michael@0: this._velocity.y = 0; michael@0: michael@0: // Set acceleration vector to opposite signs of velocity michael@0: this._acceleration.set(this._velocity.clone().map(sign).scale(-this._polynomialC)); michael@0: michael@0: this._position.set(0, 0); michael@0: this._initialTime = mozAnimationStartTime; michael@0: this._time = 0; michael@0: this.momentumBuffer = []; michael@0: michael@0: if (!this.isActive() || this._paused) michael@0: this._startTimer(); michael@0: michael@0: return true; michael@0: }, michael@0: michael@0: pause: function pause() { michael@0: this._paused = true; michael@0: }, michael@0: michael@0: end: function end() { michael@0: if (this.isActive()) { michael@0: if (this._beforeEnd) michael@0: this._beforeEnd(); michael@0: this._reset(); michael@0: } michael@0: }, michael@0: michael@0: addData: function addData(dx, dy) { michael@0: let mbLength = this.momentumBuffer.length; michael@0: let now = Date.now(); michael@0: michael@0: if (this.isActive()) { michael@0: // Stop active movement when dragging in other direction. michael@0: if (dx * this._velocity.x < 0 || dy * this._velocity.y < 0) michael@0: this.end(); michael@0: } michael@0: michael@0: this.momentumBuffer.push({'t': now, 'dx' : dx, 'dy' : dy}); michael@0: } michael@0: }; michael@0: michael@0: michael@0: /* michael@0: * Simple gestures support michael@0: */ michael@0: michael@0: var GestureModule = { michael@0: _debugEvents: false, michael@0: michael@0: init: function init() { michael@0: window.addEventListener("MozSwipeGesture", this, true); michael@0: }, michael@0: michael@0: /* michael@0: * Events michael@0: * michael@0: * Dispatch events based on the type of mouse gesture event. For now, make michael@0: * sure to stop propagation of every gesture event so that web content cannot michael@0: * receive gesture events. michael@0: * michael@0: * @param nsIDOMEvent information structure michael@0: */ michael@0: michael@0: handleEvent: function handleEvent(aEvent) { michael@0: try { michael@0: aEvent.stopPropagation(); michael@0: aEvent.preventDefault(); michael@0: if (this._debugEvents) Util.dumpLn("GestureModule:", aEvent.type); michael@0: switch (aEvent.type) { michael@0: case "MozSwipeGesture": michael@0: if (this._onSwipe(aEvent)) { michael@0: let event = document.createEvent("Events"); michael@0: event.initEvent("CancelTouchSequence", true, true); michael@0: aEvent.target.dispatchEvent(event); michael@0: } michael@0: break; michael@0: } michael@0: } catch (e) { michael@0: Util.dumpLn("Error while handling gesture event", aEvent.type, michael@0: "\nPlease report error at:", e); michael@0: Cu.reportError(e); michael@0: } michael@0: }, michael@0: michael@0: _onSwipe: function _onSwipe(aEvent) { michael@0: switch (aEvent.direction) { michael@0: case Ci.nsIDOMSimpleGestureEvent.DIRECTION_LEFT: michael@0: return this._tryCommand("cmd_forward"); michael@0: case Ci.nsIDOMSimpleGestureEvent.DIRECTION_RIGHT: michael@0: return this._tryCommand("cmd_back"); michael@0: } michael@0: return false; michael@0: }, michael@0: michael@0: _tryCommand: function _tryCommand(aId) { michael@0: if (document.getElementById(aId).getAttribute("disabled") == "true") michael@0: return false; michael@0: CommandUpdater.doCommand(aId); michael@0: return true; michael@0: }, michael@0: }; michael@0: michael@0: /** michael@0: * Helper to track when the user is using a precise pointing device (pen/mouse) michael@0: * versus an imprecise one (touch). michael@0: */ michael@0: var InputSourceHelper = { michael@0: isPrecise: false, michael@0: michael@0: init: function ish_init() { michael@0: Services.obs.addObserver(this, "metro_precise_input", false); michael@0: Services.obs.addObserver(this, "metro_imprecise_input", false); michael@0: }, michael@0: michael@0: _precise: function () { michael@0: if (!this.isPrecise) { michael@0: this.isPrecise = true; michael@0: this._fire("MozPrecisePointer"); michael@0: } michael@0: }, michael@0: michael@0: _imprecise: function () { michael@0: if (this.isPrecise) { michael@0: this.isPrecise = false; michael@0: this._fire("MozImprecisePointer"); michael@0: } michael@0: }, michael@0: michael@0: observe: function BrowserUI_observe(aSubject, aTopic, aData) { michael@0: switch (aTopic) { michael@0: case "metro_precise_input": michael@0: this._precise(); michael@0: break; michael@0: case "metro_imprecise_input": michael@0: this._imprecise(); michael@0: break; michael@0: } michael@0: }, michael@0: michael@0: fireUpdate: function fireUpdate() { michael@0: if (this.isPrecise) { michael@0: this._fire("MozPrecisePointer"); michael@0: } else { michael@0: this._fire("MozImprecisePointer"); michael@0: } michael@0: }, michael@0: michael@0: _fire: function (name) { michael@0: let event = document.createEvent("Events"); michael@0: event.initEvent(name, true, true); michael@0: window.dispatchEvent(event); michael@0: } michael@0: };