mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-11-13 23:17:57 +00:00
1243 lines
40 KiB
JavaScript
1243 lines
40 KiB
JavaScript
// -*- Mode: js2; tab-width: 2; indent-tabs-mode: nil; js2-basic-offset: 2; js2-skip-preprocessor-directives: t; js2-strict-trailing-comma-warning: nil -*-
|
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
Components.utils.import("resource://gre/modules/Geometry.jsm");
|
|
|
|
/*
|
|
* Drag scrolling related constants
|
|
*/
|
|
|
|
// maximum drag distance in inches while axis locking can still be reverted
|
|
const kAxisLockRevertThreshold = 0.8;
|
|
|
|
// Same as NS_EVENT_STATE_ACTIVE from nsIEventStateManager.h
|
|
const kStateActive = 0x00000001;
|
|
|
|
// After a drag begins, kinetic panning is stopped if the drag doesn't become
|
|
// a pan in 300 milliseconds.
|
|
const kStopKineticPanOnDragTimeout = 300;
|
|
|
|
// Min/max velocity of kinetic panning. This is in pixels/millisecond.
|
|
const kMinVelocity = 0.4;
|
|
const kMaxVelocity = 6;
|
|
|
|
/*
|
|
* prefs
|
|
*/
|
|
|
|
// A debug pref that when set makes us treat all precise pointer input
|
|
// as imprecise touch input. For debugging purposes only. Note there are
|
|
// subtle event sequencing differences in this feature when running on
|
|
// the desktop using the win32 widget backend and the winrt widget backend
|
|
// in metro. Fixing something in this mode does not insure the bug is
|
|
// in metro.
|
|
const kDebugMouseInputPref = "metro.debug.treatmouseastouch";
|
|
// Display rects around selection ranges. Useful in debugging
|
|
// selection problems.
|
|
const kDebugSelectionDisplayPref = "metro.debug.selection.displayRanges";
|
|
// Dump range rect data to the console. Very useful, but also slows
|
|
// things down a lot.
|
|
const kDebugSelectionDumpPref = "metro.debug.selection.dumpRanges";
|
|
// Dump message manager event traffic for selection.
|
|
const kDebugSelectionDumpEvents = "metro.debug.selection.dumpEvents";
|
|
const kAsyncPanZoomEnabled = "layers.async-pan-zoom.enabled"
|
|
|
|
/**
|
|
* TouchModule
|
|
*
|
|
* Handles all touch-related input such as dragging and tapping.
|
|
*
|
|
* The Fennec chrome DOM tree has elements that are augmented dynamically with
|
|
* custom JS properties that tell the TouchModule they have custom support for
|
|
* either dragging or clicking. These JS properties are JS objects that expose
|
|
* an interface supporting dragging or clicking (though currently we only look
|
|
* to drag scrollable elements).
|
|
*
|
|
* A custom dragger is a JS property that lives on a scrollable DOM element,
|
|
* accessible as myElement.customDragger. The customDragger must support the
|
|
* following interface: (The `scroller' argument is given for convenience, and
|
|
* is the object reference to the element's scrollbox object).
|
|
*
|
|
* dragStart(cX, cY, target, scroller)
|
|
* Signals the beginning of a drag. Coordinates are passed as
|
|
* client coordinates. target is copied from the event.
|
|
*
|
|
* dragStop(dx, dy, scroller)
|
|
* Signals the end of a drag. The dx, dy parameters may be non-zero to
|
|
* indicate one last drag movement.
|
|
*
|
|
* dragMove(dx, dy, scroller, isKinetic)
|
|
* Signals an input attempt to drag by dx, dy.
|
|
*
|
|
* There is a default dragger in case a scrollable element is dragged --- see
|
|
* the defaultDragger prototype property.
|
|
*/
|
|
|
|
var TouchModule = {
|
|
_debugEvents: false,
|
|
_isCancelled: false,
|
|
_isCancellable: false,
|
|
|
|
init: function init() {
|
|
this._dragData = new DragData();
|
|
|
|
this._dragger = null;
|
|
|
|
this._targetScrollbox = null;
|
|
this._targetScrollInterface = null;
|
|
|
|
this._kinetic = new KineticController(this._dragBy.bind(this),
|
|
this._kineticStop.bind(this));
|
|
|
|
// capture phase events
|
|
window.addEventListener("CancelTouchSequence", this, true);
|
|
window.addEventListener("dblclick", this, true);
|
|
|
|
// bubble phase
|
|
window.addEventListener("contextmenu", this, false);
|
|
window.addEventListener("touchstart", this, false);
|
|
window.addEventListener("touchmove", this, false);
|
|
window.addEventListener("touchend", this, false);
|
|
|
|
try {
|
|
this._treatMouseAsTouch = Services.prefs.getBoolPref(kDebugMouseInputPref);
|
|
} catch (e) {}
|
|
},
|
|
|
|
/*
|
|
* Mouse input source tracking
|
|
*/
|
|
|
|
_treatMouseAsTouch: false,
|
|
|
|
/*
|
|
* Events
|
|
*/
|
|
|
|
handleEvent: function handleEvent(aEvent) {
|
|
switch (aEvent.type) {
|
|
case "contextmenu":
|
|
this._onContextMenu(aEvent);
|
|
break;
|
|
|
|
case "CancelTouchSequence":
|
|
this.cancelPending();
|
|
break;
|
|
|
|
default: {
|
|
if (this._debugEvents) {
|
|
if (aEvent.type != "touchmove")
|
|
Util.dumpLn("TouchModule:", aEvent.type, aEvent.target);
|
|
}
|
|
|
|
switch (aEvent.type) {
|
|
case "touchstart":
|
|
this._onTouchStart(aEvent);
|
|
break;
|
|
case "touchmove":
|
|
this._onTouchMove(aEvent);
|
|
break;
|
|
case "touchend":
|
|
this._onTouchEnd(aEvent);
|
|
break;
|
|
case "dblclick":
|
|
// XXX This will get picked up somewhere below us for "double tap to zoom"
|
|
// once we get omtc and the apzc. Currently though dblclick is delivered to
|
|
// content and triggers selection of text, so fire up the SelectionHelperUI
|
|
// once selection is present.
|
|
if (!InputSourceHelper.isPrecise &&
|
|
!SelectionHelperUI.isActive &&
|
|
!FindHelperUI.isActive) {
|
|
setTimeout(function () {
|
|
SelectionHelperUI.attachEditSession(Browser.selectedTab.browser,
|
|
aEvent.clientX, aEvent.clientY);
|
|
}, 50);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
sample: function sample(aTimeStamp) {
|
|
this._waitingForPaint = false;
|
|
},
|
|
|
|
/**
|
|
* This gets invoked by the input handler if another module grabs. We should
|
|
* reset our state or something here. This is probably doing the wrong thing
|
|
* in its current form.
|
|
*/
|
|
cancelPending: function cancelPending() {
|
|
this._doDragStop();
|
|
|
|
// Kinetic panning may have already been active or drag stop above may have
|
|
// made kinetic panning active.
|
|
this._kinetic.end();
|
|
|
|
this._targetScrollbox = null;
|
|
this._targetScrollInterface = null;
|
|
},
|
|
|
|
_onContextMenu: function _onContextMenu(aEvent) {
|
|
// Special case when running on the desktop, fire off
|
|
// a edge ui event when we get the contextmenu event.
|
|
if (this._treatMouseAsTouch) {
|
|
let event = document.createEvent("Events");
|
|
event.initEvent("MozEdgeUICompleted", true, false);
|
|
window.dispatchEvent(event);
|
|
return;
|
|
}
|
|
|
|
// bug 598965 - chrome UI should stop to be pannable once the
|
|
// context menu has appeared.
|
|
if (ContextMenuUI.popupState) {
|
|
this.cancelPending();
|
|
}
|
|
},
|
|
|
|
/** Begin possible pan and send tap down event. */
|
|
_onTouchStart: function _onTouchStart(aEvent) {
|
|
if (aEvent.touches.length > 1)
|
|
return;
|
|
|
|
this._isCancelled = false;
|
|
this._isCancellable = true;
|
|
|
|
if (aEvent.defaultPrevented) {
|
|
this._isCancelled = true;
|
|
return;
|
|
}
|
|
|
|
let dragData = this._dragData;
|
|
if (dragData.dragging) {
|
|
// Somehow a mouse up was missed.
|
|
this._doDragStop();
|
|
}
|
|
dragData.reset();
|
|
this.dX = 0;
|
|
this.dY = 0;
|
|
|
|
// walk up the DOM tree in search of nearest scrollable ancestor. nulls are
|
|
// returned if none found.
|
|
let [targetScrollbox, targetScrollInterface, dragger]
|
|
= ScrollUtils.getScrollboxFromElement(aEvent.originalTarget);
|
|
|
|
// stop kinetic panning if targetScrollbox has changed
|
|
if (this._kinetic.isActive() && this._dragger != dragger)
|
|
this._kinetic.end();
|
|
|
|
this._targetScrollbox = targetScrollInterface ? targetScrollInterface.element : targetScrollbox;
|
|
this._targetScrollInterface = targetScrollInterface;
|
|
|
|
if (!this._targetScrollbox) {
|
|
return;
|
|
}
|
|
|
|
// Don't allow kinetic panning if APZC is enabled and the pan element is the deck
|
|
let deck = document.getElementById("browsers");
|
|
if (Services.prefs.getBoolPref(kAsyncPanZoomEnabled) &&
|
|
!StartUI.isStartPageVisible &&
|
|
this._targetScrollbox == deck) {
|
|
return;
|
|
}
|
|
|
|
// XXX shouldn't dragger always be valid here?
|
|
if (dragger) {
|
|
let draggable = dragger.isDraggable(targetScrollbox, targetScrollInterface);
|
|
dragData.locked = !draggable.x || !draggable.y;
|
|
if (draggable.x || draggable.y) {
|
|
this._dragger = dragger;
|
|
if (dragger.freeDrag)
|
|
dragData.alwaysFreeDrag = dragger.freeDrag();
|
|
this._doDragStart(aEvent, draggable);
|
|
}
|
|
}
|
|
},
|
|
|
|
/** Send tap up event and any necessary full taps. */
|
|
_onTouchEnd: function _onTouchEnd(aEvent) {
|
|
if (aEvent.touches.length > 0 || this._isCancelled || !this._targetScrollbox)
|
|
return;
|
|
|
|
// onMouseMove will not record the delta change if we are waiting for a
|
|
// paint. Since this is the last input for this drag, we override the flag.
|
|
this._waitingForPaint = false;
|
|
this._onTouchMove(aEvent);
|
|
|
|
let dragData = this._dragData;
|
|
this._doDragStop();
|
|
},
|
|
|
|
/**
|
|
* If we're in a drag, do what we have to do to drag on.
|
|
*/
|
|
_onTouchMove: function _onTouchMove(aEvent) {
|
|
if (aEvent.touches.length > 1)
|
|
return;
|
|
|
|
if (this._isCancellable) {
|
|
// only the first touchmove is cancellable.
|
|
this._isCancellable = false;
|
|
if (aEvent.defaultPrevented)
|
|
this._isCancelled = true;
|
|
}
|
|
|
|
if (this._isCancelled)
|
|
return;
|
|
|
|
let touch = aEvent.changedTouches[0];
|
|
if (!this._targetScrollbox) {
|
|
return;
|
|
}
|
|
|
|
let dragData = this._dragData;
|
|
|
|
if (dragData.dragging) {
|
|
let oldIsPan = dragData.isPan();
|
|
dragData.setDragPosition(touch.screenX, touch.screenY);
|
|
dragData.setMousePosition(touch);
|
|
|
|
// Kinetic panning is sensitive to time. It is more stable if it receives
|
|
// the mousemove events as they come. For dragging though, we only want
|
|
// to call _dragBy if we aren't waiting for a paint (so we don't spam the
|
|
// main browser loop with a bunch of redundant paints).
|
|
//
|
|
// Here, we feed kinetic panning drag differences for mouse events as
|
|
// come; for dragging, we build up a drag buffer in this.dX/this.dY and
|
|
// release it when we are ready to paint.
|
|
//
|
|
let [sX, sY] = dragData.panPosition();
|
|
this.dX += dragData.prevPanX - sX;
|
|
this.dY += dragData.prevPanY - sY;
|
|
|
|
if (dragData.isPan()) {
|
|
// Only pan when mouse event isn't part of a click. Prevent jittering on tap.
|
|
this._kinetic.addData(sX - dragData.prevPanX, sY - dragData.prevPanY);
|
|
|
|
// dragBy will reset dX and dY values to 0
|
|
this._dragBy(this.dX, this.dY);
|
|
|
|
// Let everyone know when mousemove begins a pan
|
|
if (!oldIsPan && dragData.isPan()) {
|
|
//this._longClickTimeout.clear();
|
|
|
|
let event = document.createEvent("Events");
|
|
event.initEvent("PanBegin", true, false);
|
|
this._targetScrollbox.dispatchEvent(event);
|
|
|
|
Browser.selectedBrowser.messageManager.sendAsyncMessage("Browser:PanBegin", {});
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Inform our dragger of a dragStart.
|
|
*/
|
|
_doDragStart: function _doDragStart(aEvent, aDraggable) {
|
|
let touch = aEvent.changedTouches[0];
|
|
let dragData = this._dragData;
|
|
dragData.setDragStart(touch.screenX, touch.screenY, aDraggable);
|
|
this._kinetic.addData(0, 0);
|
|
this._dragStartTime = Date.now();
|
|
if (!this._kinetic.isActive()) {
|
|
this._dragger.dragStart(touch.clientX, touch.clientY, touch.target, this._targetScrollInterface);
|
|
}
|
|
},
|
|
|
|
/** Finish a drag. */
|
|
_doDragStop: function _doDragStop() {
|
|
let dragData = this._dragData;
|
|
if (!dragData.dragging)
|
|
return;
|
|
|
|
dragData.endDrag();
|
|
|
|
// Note: it is possible for kinetic scrolling to be active from a
|
|
// mousedown/mouseup event previous to this one. In this case, we
|
|
// want the kinetic panner to tell our drag interface to stop.
|
|
|
|
if (dragData.isPan()) {
|
|
if (Date.now() - this._dragStartTime > kStopKineticPanOnDragTimeout)
|
|
this._kinetic._velocity.set(0, 0);
|
|
|
|
// Start kinetic pan if we i) aren't using async pan zoom or ii) if we
|
|
// are on the start page, iii) If the scroll element is not browsers
|
|
let deck = document.getElementById("browsers");
|
|
if (!Services.prefs.getBoolPref(kAsyncPanZoomEnabled) ||
|
|
StartUI.isStartPageVisible ||
|
|
this._targetScrollbox != deck) {
|
|
this._kinetic.start();
|
|
}
|
|
} else {
|
|
this._kinetic.end();
|
|
if (this._dragger)
|
|
this._dragger.dragStop(0, 0, this._targetScrollInterface);
|
|
this._dragger = null;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Used by _onTouchMove() above and by KineticController's timer to do the
|
|
* actual dragMove signalling to the dragger. We'd put this in _onTouchMove()
|
|
* but then KineticController would be adding to its own data as it signals
|
|
* the dragger of dragMove()s.
|
|
*/
|
|
_dragBy: function _dragBy(dX, dY, aIsKinetic) {
|
|
let dragged = true;
|
|
let dragData = this._dragData;
|
|
if (!this._waitingForPaint || aIsKinetic) {
|
|
let dragData = this._dragData;
|
|
dragged = this._dragger.dragMove(dX, dY, this._targetScrollInterface, aIsKinetic,
|
|
dragData._mouseX, dragData._mouseY);
|
|
if (dragged && !this._waitingForPaint) {
|
|
this._waitingForPaint = true;
|
|
mozRequestAnimationFrame(this);
|
|
}
|
|
this.dX = 0;
|
|
this.dY = 0;
|
|
}
|
|
if (!dragData.isPan())
|
|
this._kinetic.pause();
|
|
|
|
return dragged;
|
|
},
|
|
|
|
/** Callback for kinetic scroller. */
|
|
_kineticStop: function _kineticStop() {
|
|
// Kinetic panning could finish while user is panning, so don't finish
|
|
// the pan just yet.
|
|
let dragData = this._dragData;
|
|
if (!dragData.dragging) {
|
|
if (this._dragger)
|
|
this._dragger.dragStop(0, 0, this._targetScrollInterface);
|
|
this._dragger = null;
|
|
|
|
let event = document.createEvent("Events");
|
|
event.initEvent("PanFinished", true, false);
|
|
this._targetScrollbox.dispatchEvent(event);
|
|
}
|
|
},
|
|
|
|
toString: function toString() {
|
|
return '[TouchModule] {'
|
|
+ '\n\tdragData=' + this._dragData + ', '
|
|
+ 'dragger=' + this._dragger + ', '
|
|
+ '\n\ttargetScroller=' + this._targetScrollInterface + '}';
|
|
},
|
|
};
|
|
|
|
var ScrollUtils = {
|
|
// threshold in pixels for sensing a tap as opposed to a pan
|
|
get tapRadius() {
|
|
let dpi = Util.displayDPI;
|
|
delete this.tapRadius;
|
|
return this.tapRadius = Services.prefs.getIntPref("ui.dragThresholdX") / 240 * dpi;
|
|
},
|
|
|
|
/**
|
|
* Walk up (parentward) the DOM tree from elem in search of a scrollable element.
|
|
* Return the element and its scroll interface if one is found, two nulls otherwise.
|
|
*
|
|
* This function will cache the pointer to the scroll interface on the element itself,
|
|
* so it is safe to call it many times without incurring the same XPConnect overhead
|
|
* as in the initial call.
|
|
*/
|
|
getScrollboxFromElement: function getScrollboxFromElement(elem) {
|
|
let scrollbox = null;
|
|
let qinterface = null;
|
|
// if element is content, get the browser scroll interface
|
|
if (elem.ownerDocument == Browser.selectedBrowser.contentDocument) {
|
|
elem = Browser.selectedBrowser;
|
|
}
|
|
for (; elem; elem = elem.parentNode) {
|
|
try {
|
|
if (elem.anonScrollBox) {
|
|
scrollbox = elem.anonScrollBox;
|
|
qinterface = scrollbox.boxObject.QueryInterface(Ci.nsIScrollBoxObject);
|
|
} else if (elem.scrollBoxObject) {
|
|
scrollbox = elem;
|
|
qinterface = elem.scrollBoxObject;
|
|
break;
|
|
} else if (elem.customDragger) {
|
|
scrollbox = elem;
|
|
break;
|
|
} else if (elem.boxObject) {
|
|
let qi = (elem._cachedSBO) ? elem._cachedSBO
|
|
: elem.boxObject.QueryInterface(Ci.nsIScrollBoxObject);
|
|
if (qi) {
|
|
scrollbox = elem;
|
|
scrollbox._cachedSBO = qinterface = qi;
|
|
break;
|
|
}
|
|
}
|
|
} catch (e) { /* we aren't here to deal with your exceptions, we'll just keep
|
|
traversing until we find something more well-behaved, as we
|
|
prefer default behaviour to whiny scrollers. */ }
|
|
}
|
|
return [scrollbox, qinterface, (scrollbox ? (scrollbox.customDragger || this._defaultDragger) : null)];
|
|
},
|
|
|
|
/** Determine if the distance moved can be considered a pan */
|
|
isPan: function isPan(aPoint, aPoint2) {
|
|
return (Math.abs(aPoint.x - aPoint2.x) > this.tapRadius ||
|
|
Math.abs(aPoint.y - aPoint2.y) > this.tapRadius);
|
|
},
|
|
|
|
/**
|
|
* The default dragger object used by TouchModule when dragging a scrollable
|
|
* element that provides no customDragger. Simply performs the expected
|
|
* regular scrollBy calls on the scroller.
|
|
*/
|
|
_defaultDragger: {
|
|
isDraggable: function isDraggable(target, scroller) {
|
|
let sX = {}, sY = {},
|
|
pX = {}, pY = {};
|
|
scroller.getPosition(pX, pY);
|
|
scroller.getScrolledSize(sX, sY);
|
|
let rect = target.getBoundingClientRect();
|
|
return { x: (sX.value > rect.width || pX.value != 0),
|
|
y: (sY.value > rect.height || pY.value != 0) };
|
|
},
|
|
|
|
dragStart: function dragStart(cx, cy, target, scroller) {
|
|
scroller.element.addEventListener("PanBegin", this._showScrollbars, false);
|
|
},
|
|
|
|
dragStop: function dragStop(dx, dy, scroller) {
|
|
scroller.element.removeEventListener("PanBegin", this._showScrollbars, false);
|
|
return this.dragMove(dx, dy, scroller);
|
|
},
|
|
|
|
dragMove: function dragMove(dx, dy, scroller) {
|
|
if (scroller.getPosition) {
|
|
try {
|
|
let oldX = {}, oldY = {};
|
|
scroller.getPosition(oldX, oldY);
|
|
|
|
scroller.scrollBy(dx, dy);
|
|
|
|
let newX = {}, newY = {};
|
|
scroller.getPosition(newX, newY);
|
|
|
|
return (newX.value != oldX.value) || (newY.value != oldY.value);
|
|
|
|
} catch (e) { /* we have no time for whiny scrollers! */ }
|
|
}
|
|
|
|
return false;
|
|
},
|
|
|
|
_showScrollbars: function _showScrollbars(aEvent) {
|
|
let scrollbox = aEvent.target;
|
|
scrollbox.setAttribute("panning", "true");
|
|
|
|
let hideScrollbars = function() {
|
|
scrollbox.removeEventListener("PanFinished", hideScrollbars, false);
|
|
scrollbox.removeEventListener("CancelTouchSequence", hideScrollbars, false);
|
|
scrollbox.removeAttribute("panning");
|
|
}
|
|
|
|
// Wait for panning to be completely finished before removing scrollbars
|
|
scrollbox.addEventListener("PanFinished", hideScrollbars, false);
|
|
scrollbox.addEventListener("CancelTouchSequence", hideScrollbars, false);
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* DragData handles processing drags on the screen, handling both
|
|
* locking of movement on one axis, and click detection.
|
|
*/
|
|
function DragData() {
|
|
this._domUtils = Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils);
|
|
this._lockRevertThreshold = Util.displayDPI * kAxisLockRevertThreshold;
|
|
this.reset();
|
|
};
|
|
|
|
DragData.prototype = {
|
|
reset: function reset() {
|
|
this.dragging = false;
|
|
this.sX = null;
|
|
this.sY = null;
|
|
this.locked = false;
|
|
this.stayLocked = false;
|
|
this.alwaysFreeDrag = false;
|
|
this.lockedX = null;
|
|
this.lockedY = null;
|
|
this._originX = null;
|
|
this._originY = null;
|
|
this.prevPanX = null;
|
|
this.prevPanY = null;
|
|
this._isPan = false;
|
|
},
|
|
|
|
/** Depending on drag data, locks sX,sY to X-axis or Y-axis of start position. */
|
|
_lockAxis: function _lockAxis(sX, sY) {
|
|
if (this.locked) {
|
|
if (this.lockedX !== null)
|
|
sX = this.lockedX;
|
|
else if (this.lockedY !== null)
|
|
sY = this.lockedY;
|
|
return [sX, sY];
|
|
}
|
|
else {
|
|
return [this._originX, this._originY];
|
|
}
|
|
},
|
|
|
|
setMousePosition: function setMousePosition(aEvent) {
|
|
this._mouseX = aEvent.clientX;
|
|
this._mouseY = aEvent.clientY;
|
|
},
|
|
|
|
setDragPosition: function setDragPosition(sX, sY) {
|
|
// Check if drag is now a pan.
|
|
if (!this._isPan) {
|
|
this._isPan = ScrollUtils.isPan(new Point(this._originX, this._originY), new Point(sX, sY));
|
|
if (this._isPan) {
|
|
this._resetActive();
|
|
}
|
|
}
|
|
|
|
// If now a pan, mark previous position where panning was.
|
|
if (this._isPan) {
|
|
let absX = Math.abs(this._originX - sX);
|
|
let absY = Math.abs(this._originY - sY);
|
|
|
|
if (absX > this._lockRevertThreshold || absY > this._lockRevertThreshold)
|
|
this.stayLocked = true;
|
|
|
|
// After the first lock, see if locking decision should be reverted.
|
|
if (!this.stayLocked) {
|
|
if (this.lockedX && absX > 3 * absY)
|
|
this.lockedX = null;
|
|
else if (this.lockedY && absY > 3 * absX)
|
|
this.lockedY = null;
|
|
}
|
|
|
|
if (!this.locked) {
|
|
// look at difference from origin coord to lock movement, but only
|
|
// do it if initial movement is sufficient to detect intent
|
|
|
|
// divide possibilty space into eight parts. Diagonals will allow
|
|
// free movement, while moving towards a cardinal will lock that
|
|
// axis. We pick a direction if you move more than twice as far
|
|
// on one axis than another, which should be an angle of about 30
|
|
// degrees from the axis
|
|
|
|
if (absX > 2.5 * absY)
|
|
this.lockedY = sY;
|
|
else if (absY > absX)
|
|
this.lockedX = sX;
|
|
|
|
this.locked = true;
|
|
}
|
|
}
|
|
|
|
// Never lock if the dragger requests it
|
|
if (this.alwaysFreeDrag) {
|
|
this.lockedY = null;
|
|
this.lockedX = null;
|
|
}
|
|
|
|
// After pan lock, figure out previous panning position. Base it on last drag
|
|
// position so there isn't a jump in panning.
|
|
let [prevX, prevY] = this._lockAxis(this.sX, this.sY);
|
|
this.prevPanX = prevX;
|
|
this.prevPanY = prevY;
|
|
|
|
this.sX = sX;
|
|
this.sY = sY;
|
|
},
|
|
|
|
setDragStart: function setDragStart(screenX, screenY, aDraggable) {
|
|
this.sX = this._originX = screenX;
|
|
this.sY = this._originY = screenY;
|
|
this.dragging = true;
|
|
|
|
// If the target area is pannable only in one direction lock it early
|
|
// on the right axis
|
|
this.lockedX = !aDraggable.x ? screenX : null;
|
|
this.lockedY = !aDraggable.y ? screenY : null;
|
|
this.stayLocked = this.lockedX || this.lockedY;
|
|
this.locked = this.stayLocked;
|
|
},
|
|
|
|
endDrag: function endDrag() {
|
|
this._resetActive();
|
|
this.dragging = false;
|
|
},
|
|
|
|
/** Returns true if drag should pan scrollboxes.*/
|
|
isPan: function isPan() {
|
|
return this._isPan;
|
|
},
|
|
|
|
/** Return true if drag should be parsed as a click. */
|
|
isClick: function isClick() {
|
|
return !this._isPan;
|
|
},
|
|
|
|
/**
|
|
* Returns the screen position for a pan. This factors in axis locking.
|
|
* @return Array of screen X and Y coordinates
|
|
*/
|
|
panPosition: function panPosition() {
|
|
return this._lockAxis(this.sX, this.sY);
|
|
},
|
|
|
|
/** dismiss the active state of the pan element */
|
|
_resetActive: function _resetActive() {
|
|
let target = document.documentElement;
|
|
// If the target is active, toggle (turn off) the active flag. Otherwise do nothing.
|
|
if (this._domUtils.getContentState(target) & kStateActive)
|
|
this._domUtils.setContentState(target, kStateActive);
|
|
},
|
|
|
|
toString: function toString() {
|
|
return '[DragData] { sX,sY=' + this.sX + ',' + this.sY + ', dragging=' + this.dragging + ' }';
|
|
}
|
|
};
|
|
|
|
|
|
/**
|
|
* KineticController - a class to take drag position data and use it
|
|
* to do kinetic panning of a scrollable object.
|
|
*
|
|
* aPanBy is a function that will be called with the dx and dy
|
|
* generated by the kinetic algorithm. It should return true if the
|
|
* object was panned, false if there was no movement.
|
|
*
|
|
* There are two complicated things done here. One is calculating the
|
|
* initial velocity of the movement based on user input. Two is
|
|
* calculating the distance to move every frame.
|
|
*/
|
|
function KineticController(aPanBy, aEndCallback) {
|
|
this._panBy = aPanBy;
|
|
this._beforeEnd = aEndCallback;
|
|
|
|
// These are used to calculate the position of the scroll panes during kinetic panning. Think of
|
|
// these points as vectors that are added together and multiplied by scalars.
|
|
this._position = new Point(0, 0);
|
|
this._velocity = new Point(0, 0);
|
|
this._acceleration = new Point(0, 0);
|
|
this._time = 0;
|
|
this._timeStart = 0;
|
|
|
|
// How often do we change the position of the scroll pane? Too often and panning may jerk near
|
|
// the end. Too little and panning will be choppy. In milliseconds.
|
|
this._updateInterval = Services.prefs.getIntPref("browser.ui.kinetic.updateInterval");
|
|
// Constants that affect the "friction" of the scroll pane.
|
|
this._exponentialC = Services.prefs.getIntPref("browser.ui.kinetic.exponentialC");
|
|
this._polynomialC = Services.prefs.getIntPref("browser.ui.kinetic.polynomialC") / 1000000;
|
|
// Number of milliseconds that can contain a swipe. Movements earlier than this are disregarded.
|
|
this._swipeLength = Services.prefs.getIntPref("browser.ui.kinetic.swipeLength");
|
|
|
|
this._reset();
|
|
}
|
|
|
|
KineticController.prototype = {
|
|
_reset: function _reset() {
|
|
this._active = false;
|
|
this._paused = false;
|
|
this.momentumBuffer = [];
|
|
this._velocity.set(0, 0);
|
|
},
|
|
|
|
isActive: function isActive() {
|
|
return this._active;
|
|
},
|
|
|
|
_startTimer: function _startTimer() {
|
|
let self = this;
|
|
|
|
let lastp = this._position; // track last position vector because pan takes deltas
|
|
let v0 = this._velocity; // initial velocity
|
|
let a = this._acceleration; // acceleration
|
|
let c = this._exponentialC;
|
|
let p = new Point(0, 0);
|
|
let dx, dy, t, realt;
|
|
|
|
function calcP(v0, a, t) {
|
|
// Important traits for this function:
|
|
// p(t=0) is 0
|
|
// p'(t=0) is v0
|
|
//
|
|
// We use exponential to get a smoother stop, but by itself exponential
|
|
// is too smooth at the end. Adding a polynomial with the appropriate
|
|
// weight helps to balance
|
|
return v0 * Math.exp(-t / c) * -c + a * t * t + v0 * c;
|
|
}
|
|
|
|
this._calcV = function(v0, a, t) {
|
|
return v0 * Math.exp(-t / c) + 2 * a * t;
|
|
}
|
|
|
|
let callback = {
|
|
sample: function kineticHandleEvent(timeStamp) {
|
|
// Someone called end() on us between timer intervals
|
|
// or we are paused.
|
|
if (!self.isActive() || self._paused)
|
|
return;
|
|
|
|
// To make animation end fast enough but to keep smoothness, average the ideal
|
|
// time frame (smooth animation) with the actual time lapse (end fast enough).
|
|
// Animation will never take longer than 2 times the ideal length of time.
|
|
realt = timeStamp - self._initialTime;
|
|
self._time += self._updateInterval;
|
|
t = (self._time + realt) / 2;
|
|
|
|
// Calculate new position.
|
|
p.x = calcP(v0.x, a.x, t);
|
|
p.y = calcP(v0.y, a.y, t);
|
|
dx = Math.round(p.x - lastp.x);
|
|
dy = Math.round(p.y - lastp.y);
|
|
|
|
// Test to see if movement is finished for each component.
|
|
if (dx * a.x > 0) {
|
|
dx = 0;
|
|
lastp.x = 0;
|
|
v0.x = 0;
|
|
a.x = 0;
|
|
}
|
|
// Symmetric to above case.
|
|
if (dy * a.y > 0) {
|
|
dy = 0;
|
|
lastp.y = 0;
|
|
v0.y = 0;
|
|
a.y = 0;
|
|
}
|
|
|
|
if (v0.x == 0 && v0.y == 0) {
|
|
self.end();
|
|
} else {
|
|
let panStop = false;
|
|
if (dx != 0 || dy != 0) {
|
|
try { panStop = !self._panBy(-dx, -dy, true); } catch (e) {}
|
|
lastp.add(dx, dy);
|
|
}
|
|
|
|
if (panStop)
|
|
self.end();
|
|
else
|
|
mozRequestAnimationFrame(this);
|
|
}
|
|
}
|
|
};
|
|
|
|
this._active = true;
|
|
this._paused = false;
|
|
mozRequestAnimationFrame(callback);
|
|
},
|
|
|
|
start: function start() {
|
|
function sign(x) {
|
|
return x ? ((x > 0) ? 1 : -1) : 0;
|
|
}
|
|
|
|
function clampFromZero(x, closerToZero, furtherFromZero) {
|
|
if (x >= 0)
|
|
return Math.max(closerToZero, Math.min(furtherFromZero, x));
|
|
return Math.min(-closerToZero, Math.max(-furtherFromZero, x));
|
|
}
|
|
|
|
let mb = this.momentumBuffer;
|
|
let mblen = this.momentumBuffer.length;
|
|
|
|
let lastTime = mb[mblen - 1].t;
|
|
let distanceX = 0;
|
|
let distanceY = 0;
|
|
let swipeLength = this._swipeLength;
|
|
|
|
// determine speed based on recorded input
|
|
let me;
|
|
for (let i = 0; i < mblen; i++) {
|
|
me = mb[i];
|
|
if (lastTime - me.t < swipeLength) {
|
|
distanceX += me.dx;
|
|
distanceY += me.dy;
|
|
}
|
|
}
|
|
|
|
let currentVelocityX = 0;
|
|
let currentVelocityY = 0;
|
|
|
|
if (this.isActive()) {
|
|
// If active, then we expect this._calcV to be defined.
|
|
let currentTime = Date.now() - this._initialTime;
|
|
currentVelocityX = Util.clamp(this._calcV(this._velocity.x, this._acceleration.x, currentTime), -kMaxVelocity, kMaxVelocity);
|
|
currentVelocityY = Util.clamp(this._calcV(this._velocity.y, this._acceleration.y, currentTime), -kMaxVelocity, kMaxVelocity);
|
|
}
|
|
|
|
if (currentVelocityX * this._velocity.x <= 0)
|
|
currentVelocityX = 0;
|
|
if (currentVelocityY * this._velocity.y <= 0)
|
|
currentVelocityY = 0;
|
|
|
|
let swipeTime = Math.min(swipeLength, lastTime - mb[0].t);
|
|
this._velocity.x = clampFromZero((distanceX / swipeTime) + currentVelocityX, Math.abs(currentVelocityX), kMaxVelocity);
|
|
this._velocity.y = clampFromZero((distanceY / swipeTime) + currentVelocityY, Math.abs(currentVelocityY), kMaxVelocity);
|
|
|
|
if (Math.abs(this._velocity.x) < kMinVelocity)
|
|
this._velocity.x = 0;
|
|
if (Math.abs(this._velocity.y) < kMinVelocity)
|
|
this._velocity.y = 0;
|
|
|
|
// Set acceleration vector to opposite signs of velocity
|
|
this._acceleration.set(this._velocity.clone().map(sign).scale(-this._polynomialC));
|
|
|
|
this._position.set(0, 0);
|
|
this._initialTime = mozAnimationStartTime;
|
|
this._time = 0;
|
|
this.momentumBuffer = [];
|
|
|
|
if (!this.isActive() || this._paused)
|
|
this._startTimer();
|
|
|
|
return true;
|
|
},
|
|
|
|
pause: function pause() {
|
|
this._paused = true;
|
|
},
|
|
|
|
end: function end() {
|
|
if (this.isActive()) {
|
|
if (this._beforeEnd)
|
|
this._beforeEnd();
|
|
this._reset();
|
|
}
|
|
},
|
|
|
|
addData: function addData(dx, dy) {
|
|
let mbLength = this.momentumBuffer.length;
|
|
let now = Date.now();
|
|
|
|
if (this.isActive()) {
|
|
// Stop active movement when dragging in other direction.
|
|
if (dx * this._velocity.x < 0 || dy * this._velocity.y < 0)
|
|
this.end();
|
|
}
|
|
|
|
this.momentumBuffer.push({'t': now, 'dx' : dx, 'dy' : dy});
|
|
}
|
|
};
|
|
|
|
|
|
/**
|
|
* Input module for basic scrollwheel input. Currently just zooms the browser
|
|
* view accordingly.
|
|
*/
|
|
var ScrollwheelModule = {
|
|
_pendingEvent : 0,
|
|
_container: null,
|
|
|
|
init: function init(container) {
|
|
this._container = container;
|
|
window.addEventListener("MozPrecisePointer", this, true);
|
|
window.addEventListener("MozImprecisePointer", this, true);
|
|
},
|
|
|
|
handleEvent: function handleEvent(aEvent) {
|
|
switch(aEvent.type) {
|
|
case "DOMMouseScroll":
|
|
case "MozMousePixelScroll":
|
|
this._onScroll(aEvent);
|
|
break;
|
|
case "MozPrecisePointer":
|
|
this._container.removeEventListener("DOMMouseScroll", this, true);
|
|
this._container.removeEventListener("MozMousePixelScroll", this, true);
|
|
break;
|
|
case "MozImprecisePointer":
|
|
this._container.addEventListener("DOMMouseScroll", this, true);
|
|
this._container.addEventListener("MozMousePixelScroll", this, true);
|
|
break;
|
|
};
|
|
},
|
|
|
|
_onScroll: function _onScroll(aEvent) {
|
|
// If events come too fast we don't want their handling to lag the
|
|
// zoom in/zoom out execution. With the timeout the zoom is executed
|
|
// as we scroll.
|
|
if (this._pendingEvent)
|
|
clearTimeout(this._pendingEvent);
|
|
|
|
this._pendingEvent = setTimeout(function handleEventImpl(self) {
|
|
self._pendingEvent = 0;
|
|
Browser.zoom(aEvent.detail);
|
|
}, 0, this);
|
|
|
|
aEvent.stopPropagation();
|
|
aEvent.preventDefault();
|
|
},
|
|
|
|
/* We don't have much state to reset if we lose event focus */
|
|
cancelPending: function cancelPending() {}
|
|
};
|
|
|
|
|
|
/*
|
|
* Simple gestures support
|
|
*/
|
|
|
|
var GestureModule = {
|
|
_debugEvents: false,
|
|
|
|
init: function init() {
|
|
window.addEventListener("MozSwipeGesture", this, true);
|
|
/*
|
|
window.addEventListener("MozMagnifyGestureStart", this, true);
|
|
window.addEventListener("MozMagnifyGestureUpdate", this, true);
|
|
window.addEventListener("MozMagnifyGesture", this, true);
|
|
*/
|
|
window.addEventListener("CancelTouchSequence", this, true);
|
|
},
|
|
|
|
_initMouseEventFromGestureEvent: function _initMouseEventFromGestureEvent(aDestEvent, aSrcEvent, aType, aCanBubble, aCancellable) {
|
|
aDestEvent.initMouseEvent(aType, aCanBubble, aCancellable, window, null,
|
|
aSrcEvent.screenX, aSrcEvent.screenY, aSrcEvent.clientX, aSrcEvent.clientY,
|
|
aSrcEvent.ctrlKey, aSrcEvent.altKey, aSrcEvent.shiftKey, aSrcEvent.metaKey,
|
|
aSrcEvent.button, aSrcEvent.relatedTarget);
|
|
},
|
|
|
|
/*
|
|
* Events
|
|
*
|
|
* Dispatch events based on the type of mouse gesture event. For now, make
|
|
* sure to stop propagation of every gesture event so that web content cannot
|
|
* receive gesture events.
|
|
*
|
|
* @param nsIDOMEvent information structure
|
|
*/
|
|
|
|
handleEvent: function handleEvent(aEvent) {
|
|
try {
|
|
aEvent.stopPropagation();
|
|
aEvent.preventDefault();
|
|
if (this._debugEvents) Util.dumpLn("GestureModule:", aEvent.type);
|
|
switch (aEvent.type) {
|
|
case "MozSwipeGesture":
|
|
if (this._onSwipe(aEvent)) {
|
|
let event = document.createEvent("Events");
|
|
event.initEvent("CancelTouchSequence", true, true);
|
|
aEvent.target.dispatchEvent(event);
|
|
}
|
|
break;
|
|
|
|
// Magnify currently doesn't work for Win8 (bug 593168)
|
|
/*
|
|
case "MozMagnifyGestureStart":
|
|
this._pinchStart(aEvent);
|
|
break;
|
|
|
|
case "MozMagnifyGestureUpdate":
|
|
this._pinchUpdate(aEvent);
|
|
break;
|
|
|
|
case "MozMagnifyGesture":
|
|
this._pinchEnd(aEvent);
|
|
break;
|
|
*/
|
|
|
|
case "CancelTouchSequence":
|
|
this.cancelPending();
|
|
break;
|
|
}
|
|
} catch (e) {
|
|
Util.dumpLn("Error while handling gesture event", aEvent.type,
|
|
"\nPlease report error at:", e);
|
|
Cu.reportError(e);
|
|
}
|
|
},
|
|
|
|
/*
|
|
* Event handlers
|
|
*/
|
|
|
|
cancelPending: function cancelPending() {
|
|
if (AnimatedZoom.isZooming())
|
|
AnimatedZoom.finish();
|
|
},
|
|
|
|
_onSwipe: function _onSwipe(aEvent) {
|
|
switch (aEvent.direction) {
|
|
case Ci.nsIDOMSimpleGestureEvent.DIRECTION_LEFT:
|
|
return this._tryCommand("cmd_forward");
|
|
case Ci.nsIDOMSimpleGestureEvent.DIRECTION_RIGHT:
|
|
return this._tryCommand("cmd_back");
|
|
}
|
|
return false;
|
|
},
|
|
|
|
_tryCommand: function _tryCommand(aId) {
|
|
if (document.getElementById(aId).getAttribute("disabled") == "true")
|
|
return false;
|
|
CommandUpdater.doCommand(aId);
|
|
return true;
|
|
},
|
|
|
|
_pinchStart: function _pinchStart(aEvent) {
|
|
if (AnimatedZoom.isZooming())
|
|
return;
|
|
// Cancel other touch sequence events, and be courteous by allowing them
|
|
// to say no.
|
|
let event = document.createEvent("Events");
|
|
event.initEvent("CancelTouchSequence", true, true);
|
|
let success = aEvent.target.dispatchEvent(event);
|
|
|
|
if (!success || !Browser.selectedTab.allowZoom)
|
|
return;
|
|
|
|
AnimatedZoom.start();
|
|
this._pinchDelta = 0;
|
|
|
|
//this._ignoreNextUpdate = true; // first update gives useless, huge delta
|
|
|
|
// cache gesture limit values
|
|
this._maxGrowth = Services.prefs.getIntPref("browser.ui.pinch.maxGrowth");
|
|
this._maxShrink = Services.prefs.getIntPref("browser.ui.pinch.maxShrink");
|
|
this._scalingFactor = Services.prefs.getIntPref("browser.ui.pinch.scalingFactor");
|
|
|
|
// Adjust the client coordinates to be relative to the browser element's top left corner.
|
|
this._browserBCR = getBrowser().getBoundingClientRect();
|
|
this._pinchStartX = aEvent.clientX - this._browserBCR.left;
|
|
this._pinchStartY = aEvent.clientY - this._browserBCR.top;
|
|
},
|
|
|
|
_pinchUpdate: function _pinchUpdate(aEvent) {
|
|
if (!AnimatedZoom.isZooming() || !aEvent.delta)
|
|
return;
|
|
|
|
let delta = 0;
|
|
let browser = AnimatedZoom.browser;
|
|
let oldScale = browser.scale;
|
|
let bcr = this._browserBCR;
|
|
|
|
// Accumulate pinch delta. Small changes are just jitter.
|
|
this._pinchDelta += aEvent.delta;
|
|
if (Math.abs(this._pinchDelta) >= oldScale) {
|
|
delta = this._pinchDelta;
|
|
this._pinchDelta = 0;
|
|
}
|
|
|
|
// decrease the pinchDelta min/max values to limit zooming out/in speed
|
|
delta = Util.clamp(delta, -this._maxShrink, this._maxGrowth);
|
|
|
|
let newScale = Browser.selectedTab.clampZoomLevel(oldScale * (1 + delta / this._scalingFactor));
|
|
let startScale = AnimatedZoom.startScale;
|
|
let scaleRatio = startScale / newScale;
|
|
let cX = aEvent.clientX - bcr.left;
|
|
let cY = aEvent.clientY - bcr.top;
|
|
|
|
// Calculate the new zoom rect.
|
|
let rect = AnimatedZoom.zoomFrom.clone();
|
|
rect.translate(this._pinchStartX - cX + (1-scaleRatio) * cX * rect.width / bcr.width,
|
|
this._pinchStartY - cY + (1-scaleRatio) * cY * rect.height / bcr.height);
|
|
|
|
rect.width *= scaleRatio;
|
|
rect.height *= scaleRatio;
|
|
|
|
this.translateInside(rect, new Rect(0, 0, browser.contentDocumentWidth * startScale,
|
|
browser.contentDocumentHeight * startScale));
|
|
|
|
// redraw zoom canvas according to new zoom rect
|
|
AnimatedZoom.updateTo(rect);
|
|
},
|
|
|
|
_pinchEnd: function _pinchEnd(aEvent) {
|
|
if (AnimatedZoom.isZooming())
|
|
AnimatedZoom.finish();
|
|
},
|
|
|
|
/**
|
|
* Ensure r0 is inside r1, if possible. Preserves w, h.
|
|
* Same as Rect.prototype.translateInside except it aligns the top left
|
|
* instead of the bottom right if r0 is bigger than r1.
|
|
*/
|
|
translateInside: function translateInside(r0, r1) {
|
|
let offsetX = (r0.left < r1.left ? r1.left - r0.left :
|
|
(r0.right > r1.right ? Math.max(r1.left - r0.left, r1.right - r0.right) : 0));
|
|
let offsetY = (r0.top < r1.top ? r1.top - r0.top :
|
|
(r0.bottom > r1.bottom ? Math.max(r1.top - r0.top, r1.bottom - r0.bottom) : 0));
|
|
return r0.translate(offsetX, offsetY);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Helper to track when the user is using a precise pointing device (pen/mouse)
|
|
* versus an imprecise one (touch).
|
|
*/
|
|
var InputSourceHelper = {
|
|
isPrecise: false,
|
|
touchIsActive: false,
|
|
|
|
init: function ish_init() {
|
|
window.addEventListener("mousemove", this, true);
|
|
window.addEventListener("mousedown", this, true);
|
|
window.addEventListener("touchstart", this, true);
|
|
window.addEventListener("touchend", this, true);
|
|
},
|
|
|
|
_precise: function () {
|
|
if (!this.isPrecise) {
|
|
this.isPrecise = true;
|
|
this._fire("MozPrecisePointer");
|
|
}
|
|
},
|
|
|
|
_imprecise: function () {
|
|
if (this.isPrecise) {
|
|
this.isPrecise = false;
|
|
this._fire("MozImprecisePointer");
|
|
}
|
|
},
|
|
|
|
handleEvent: function ish_handleEvent(aEvent) {
|
|
switch(aEvent.type) {
|
|
case "touchstart":
|
|
this._imprecise();
|
|
this.touchIsActive = true;
|
|
break;
|
|
case "touchend":
|
|
this.touchIsActive = false;
|
|
break;
|
|
default:
|
|
// Ignore mouse movement when touch is active. Prevents both mouse scrollbars
|
|
// and touch scrollbars from displaying at the same time. Also works around
|
|
// odd win8 bug involving an erant mousemove event after a touch sequence
|
|
// starts (bug 896017).
|
|
if (this.touchIsActive) {
|
|
return;
|
|
}
|
|
|
|
switch (aEvent.mozInputSource) {
|
|
case Ci.nsIDOMMouseEvent.MOZ_SOURCE_MOUSE:
|
|
case Ci.nsIDOMMouseEvent.MOZ_SOURCE_PEN:
|
|
case Ci.nsIDOMMouseEvent.MOZ_SOURCE_ERASER:
|
|
case Ci.nsIDOMMouseEvent.MOZ_SOURCE_CURSOR:
|
|
this._precise();
|
|
break;
|
|
}
|
|
break;
|
|
}
|
|
},
|
|
|
|
fireUpdate: function fireUpdate() {
|
|
if (this.isPrecise) {
|
|
this._fire("MozPrecisePointer");
|
|
} else {
|
|
this._fire("MozImprecisePointer");
|
|
}
|
|
},
|
|
|
|
_fire: function (name) {
|
|
let event = document.createEvent("Events");
|
|
event.initEvent(name, true, true);
|
|
window.dispatchEvent(event);
|
|
}
|
|
};
|