Web   ·   Wiki   ·   Activities   ·   Blog   ·   Lists   ·   Chat   ·   Meeting   ·   Bugs   ·   Git   ·   Translate   ·   Archive   ·   People   ·   Donate
summaryrefslogtreecommitdiffstats
path: root/shared/js/gesture_detector.js
diff options
context:
space:
mode:
Diffstat (limited to 'shared/js/gesture_detector.js')
-rw-r--r--shared/js/gesture_detector.js891
1 files changed, 891 insertions, 0 deletions
diff --git a/shared/js/gesture_detector.js b/shared/js/gesture_detector.js
new file mode 100644
index 0000000..61e2759
--- /dev/null
+++ b/shared/js/gesture_detector.js
@@ -0,0 +1,891 @@
+/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- /
+/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */
+
+'use strict';
+
+/**
+ * GestureDetector.js: generate events for one and two finger gestures.
+ *
+ * A GestureDetector object listens for touch and mouse events on a specified
+ * element and generates higher-level events that describe one and two finger
+ * gestures on the element. The hope is that this will be useful for webapps
+ * that need to run on mouse (or trackpad)-based desktop browsers and also in
+ * touch-based mobile devices.
+ *
+ * Supported events:
+ *
+ * tap like a click event
+ * dbltap like dblclick
+ * pan one finger motion, or mousedown followed by mousemove
+ * swipe when a finger is released following pan events
+ * holdstart touch (or mousedown) and hold. Must set an option to get these.
+ * holdmove motion after a holdstart event
+ * holdend when the finger or mouse goes up after holdstart/holdmove
+ * transform 2-finger pinch and twist gestures for scaling and rotation
+ * These are touch-only; they can't be simulated with a mouse.
+ *
+ * Each of these events is a bubbling CustomEvent with important details in the
+ * event.detail field. The event details are not yet stable and are not yet
+ * documented. See the calls to emitEvent() for details.
+ *
+ * To use this library, create a GestureDetector object by passing an element to
+ * the GestureDetector() constructor and then calling startDetecting() on it.
+ * The element will be the target of all the emitted gesture events. You can
+ * also pass an optional object as the second constructor argument. If you're
+ * interested in holdstart/holdmove/holdend events, pass {holdEvents:true} as
+ * this second argument. Otherwise they will not be generated.
+ *
+ * Implementation note: event processing is done with a simple finite-state
+ * machine. This means that in general, the various kinds of gestures are
+ * mutually exclusive. You won't get pan events until your finger or mouse has
+ * moved more than a minimum threshold, for example, but it does, the FSM enters
+ * a new state in which it can emit pan and swipe events and cannot emit hold
+ * events. Similarly, if you've started a 1 finger pan/swipe gesture and
+ * accidentally touch with a second finger, you'll continue to get pan events,
+ * and won't suddenly start getting 2-finger transform events.
+ *
+ * This library never calls preventDefault() or stopPropagation on any of the
+ * events it processes, so the raw touch or mouse events should still be
+ * available for other code to process. It is not clear to me whether this is a
+ * feature or a bug.
+ */
+
+var GestureDetector = (function() {
+
+ //
+ // Constructor
+ //
+ function GD(e, options) {
+ this.element = e;
+ this.options = options || {};
+ this.state = initialState;
+ this.timers = {};
+ this.listeningForMouseEvents = true;
+ }
+
+ //
+ // Public methods
+ //
+
+ GD.prototype.startDetecting = function() {
+ var self = this;
+ eventtypes.forEach(function(t) {
+ self.element.addEventListener(t, self);
+ });
+ };
+
+ GD.prototype.stopDetecting = function() {
+ var self = this;
+ eventtypes.forEach(function(t) {
+ self.element.removeEventListener(t, self);
+ });
+ };
+
+ //
+ // Internal methods
+ //
+
+ GD.prototype.handleEvent = function(e) {
+ var handler = this.state[e.type];
+ if (!handler) return;
+
+ // If this is a touch event handle each changed touch separately
+ if (e.changedTouches) {
+ // If we ever receive a touch event, then we know we are on a
+ // touch device and we stop listening for mouse events. If we
+ // don't do that, then the touchstart touchend mousedown mouseup
+ // generated by a single tap gesture will cause us to output
+ // tap tap dbltap, which is wrong
+ if (this.listeningForMouseEvents) {
+ this.listeningForMouseEvents = false;
+ this.element.removeEventListener('mousedown', this);
+ }
+
+ // XXX https://bugzilla.mozilla.org/show_bug.cgi?id=785554
+ // causes touchend events to list all touches as changed, so
+ // warn if we see that bug
+ if (e.type === 'touchend' && e.changedTouches.length > 1) {
+ console.warn('gesture_detector.js: spurious extra changed touch on ' +
+ 'touchend. See ' +
+ 'https://bugzilla.mozilla.org/show_bug.cgi?id=785554');
+ }
+
+ for (var i = 0; i < e.changedTouches.length; i++) {
+ handler(this, e, e.changedTouches[i]);
+ // The first changed touch might have changed the state of the
+ // FSM. We need this line to workaround the bug 785554, but it is
+ // probably the right thing to have here, even once that bug is fixed.
+ handler = this.state[e.type];
+ }
+ }
+ else { // Otherwise, just dispatch the event to the handler
+ handler(this, e);
+ }
+ };
+
+ GD.prototype.startTimer = function(type, time) {
+ this.clearTimer(type);
+ var self = this;
+ this.timers[type] = setTimeout(function() {
+ self.timers[type] = null;
+ var handler = self.state[type];
+ if (handler)
+ handler(self, type);
+ }, time);
+ };
+
+ GD.prototype.clearTimer = function(type) {
+ if (this.timers[type]) {
+ clearTimeout(this.timers[type]);
+ this.timers[type] = null;
+ }
+ };
+
+ // Switch to a new FSM state, and call the init() function of that
+ // state, if it has one. The event and touch arguments are optional
+ // and are just passed through to the state init function.
+ GD.prototype.switchTo = function(state, event, touch) {
+ this.state = state;
+ if (state.init)
+ state.init(this, event, touch);
+ };
+
+ GD.prototype.emitEvent = function(type, detail) {
+ if (!this.target) {
+ console.error('Attempt to emit event with no target');
+ return;
+ }
+
+ var event = this.element.ownerDocument.createEvent('CustomEvent');
+ event.initCustomEvent(type, true, true, detail);
+ this.target.dispatchEvent(event);
+ }
+
+ //
+ // Tuneable parameters
+ //
+ GD.HOLD_INTERVAL = 1000; // Hold events after 1000 ms
+ GD.PAN_THRESHOLD = 20; // 20 pixels movement before touch panning
+ GD.MOUSE_PAN_THRESHOLD = 15; // Mice are more precise, so smaller threshold
+ GD.DOUBLE_TAP_DISTANCE = 50;
+ GD.DOUBLE_TAP_TIME = 500;
+ GD.VELOCITY_SMOOTHING = .5;
+
+ // Don't start sending transform events until the gesture exceeds a threshold
+ GD.SCALE_THRESHOLD = 20; // pixels
+ GD.ROTATE_THRESHOLD = 22.5; // degrees
+
+ // For pans and zooms, we compute new starting coordinates that are part way
+ // between the initial event and the event that crossed the threshold so that
+ // the first event we send doesn't cause a big lurch. This constant must be
+ // between 0 and 1 and says how far along the line between the initial value
+ // and the new value we pick
+ GD.THRESHOLD_SMOOTHING = 0.9;
+
+ //
+ // Helpful shortcuts and utility functions
+ //
+
+ var abs = Math.abs, floor = Math.floor, sqrt = Math.sqrt, atan2 = Math.atan2;
+ var PI = Math.PI;
+
+ // The names of events that we need to register handlers for
+ var eventtypes = [
+ 'touchstart',
+ 'touchmove',
+ 'touchend',
+ 'mousedown' // We register mousemove and mouseup manually
+ ];
+
+ // Return the event's timestamp in ms
+ function eventTime(e) {
+ // In gecko, synthetic events seem to be in microseconds rather than ms.
+ // So if the timestamp is much larger than the current time, assue it is
+ // in microseconds and divide by 1000
+ var ts = e.timeStamp;
+ if (ts > 2 * Date.now())
+ return Math.floor(ts / 1000);
+ else
+ return ts;
+ }
+
+
+ // Return an object containg the space and time coordinates of
+ // and event and touch. We freeze the object to make it immutable so
+ // we can pass it in events and not worry about values being changed.
+ function coordinates(e, t) {
+ return Object.freeze({
+ screenX: t.screenX,
+ screenY: t.screenY,
+ clientX: t.clientX,
+ clientY: t.clientY,
+ timeStamp: eventTime(e)
+ });
+ }
+
+ // Like coordinates(), but return the midpoint between two touches
+ function midpoints(e, t1, t2) {
+ return Object.freeze({
+ screenX: floor((t1.screenX + t2.screenX) / 2),
+ screenY: floor((t1.screenY + t2.screenY) / 2),
+ clientX: floor((t1.clientX + t2.clientX) / 2),
+ clientY: floor((t1.clientY + t2.clientY) / 2),
+ timeStamp: eventTime(e)
+ });
+ }
+
+ // Like coordinates(), but for a mouse event
+ function mouseCoordinates(e) {
+ return Object.freeze({
+ screenX: e.screenX,
+ screenY: e.screenY,
+ clientX: e.clientX,
+ clientY: e.clientY,
+ timeStamp: eventTime(e)
+ });
+ }
+
+ // Given coordinates objects c1 and c2, return a new coordinates object
+ // representing a point and time along the line between those points.
+ // The position of the point is controlled by the THRESHOLD_SMOOTHING constant
+ function between(c1, c2) {
+ var r = GD.THRESHOLD_SMOOTHING;
+ return Object.freeze({
+ screenX: floor(c1.screenX + r * (c2.screenX - c1.screenX)),
+ screenY: floor(c1.screenY + r * (c2.screenY - c1.screenY)),
+ clientX: floor(c1.clientX + r * (c2.clientX - c1.clientX)),
+ clientY: floor(c1.clientY + r * (c2.clientY - c1.clientY)),
+ timeStamp: floor(c1.timeStamp + r * (c2.timeStamp - c1.timeStamp))
+ });
+ }
+
+ // Compute the distance between two touches
+ function touchDistance(t1, t2) {
+ var dx = t2.screenX - t1.screenX;
+ var dy = t2.screenY - t1.screenY;
+ return sqrt(dx * dx + dy * dy);
+ }
+
+ // Compute the direction (as an angle) of the line between two touches
+ // Returns a number d, -180 < d <= 180
+ function touchDirection(t1, t2) {
+ return atan2(t2.screenY - t1.screenY,
+ t2.screenX - t1.screenX) * 180 / PI;
+ }
+
+ // Compute the clockwise angle between direction d1 and direction d2.
+ // Returns an angle a -180 < a <= 180.
+ function touchRotation(d1, d2) {
+ var angle = d2 - d1;
+ if (angle > 180)
+ angle -= 360;
+ else if (angle <= -180)
+ angle += 360;
+ return angle;
+ }
+
+ // Determine if two taps are close enough in time and space to
+ // trigger a dbltap event. The arguments are objects returned
+ // by the coordinates() function.
+ function isDoubleTap(lastTap, thisTap) {
+ var dx = abs(thisTap.screenX - lastTap.screenX);
+ var dy = abs(thisTap.screenY - lastTap.screenY);
+ var dt = thisTap.timeStamp - lastTap.timeStamp;
+ return (dx < GD.DOUBLE_TAP_DISTANCE &&
+ dy < GD.DOUBLE_TAP_DISTANCE &&
+ dt < GD.DOUBLE_TAP_TIME);
+ }
+
+ //
+ // The following objects are the states of our Finite State Machine
+ //
+
+ // In this state we're not processing any gestures, just waiting
+ // for an event to start a gesture and ignoring others
+ var initialState = {
+ name: 'initialState',
+ init: function(d) {
+ // When we enter or return to the initial state, clear
+ // the detector properties that were tracking gestures
+ // Don't clear d.lastTap here, though. We need it for dbltap events
+ d.target = null;
+ d.start = d.last = null;
+ d.touch1 = d.touch2 = null;
+ d.vx = d.vy = null;
+ d.startDistance = d.lastDistance = null;
+ d.startDirection = d.lastDirection = null;
+ d.lastMidpoint = null;
+ d.scaled = d.rotated = null;
+ },
+
+ // Switch to the touchstarted state and process the touch event there
+ // Once we've started processing a touch gesture we'll ignore mouse events
+ touchstart: function(d, e, t) {
+ d.switchTo(touchStartedState, e, t);
+ },
+
+ // Or if we see a mouse event first, then start processing a mouse-based
+ // gesture, and ignore any touch events
+ mousedown: function(d, e) {
+ d.switchTo(mouseDownState, e);
+ }
+ };
+
+ // One finger is down but we haven't generated any event yet. We're
+ // waiting to see... If the finger goes up soon, its a tap. If the finger
+ // stays down and still, its a hold. If the finger moves its a pan/swipe.
+ // And if a second finger goes down, its a transform
+ var touchStartedState = {
+ name: 'touchStartedState',
+ init: function(d, e, t) {
+ // Remember the target of the event
+ d.target = e.target;
+ // Remember the id of the touch that started
+ d.touch1 = t.identifier;
+ // Get the coordinates of the touch
+ d.start = d.last = coordinates(e, t);
+ // Start a timer for a hold
+ // If we're doing hold events, start a timer for them
+ if (d.options.holdEvents)
+ d.startTimer('holdtimeout', GD.HOLD_INTERVAL);
+ },
+
+ touchstart: function(d, e, t) {
+ // If another finger goes down in this state, then
+ // go to transform state to start 2-finger gestures.
+ d.clearTimer('holdtimeout');
+ d.switchTo(transformState, e, t);
+ },
+ touchmove: function(d, e, t) {
+ // Ignore any touches but the initial one
+ // This could happen if there was still a finger down after
+ // the end of a previous 2-finger gesture, e.g.
+ if (t.identifier !== d.touch1)
+ return;
+
+ if (abs(t.screenX - d.start.screenX) > GD.PAN_THRESHOLD ||
+ abs(t.screenY - d.start.screenY) > GD.PAN_THRESHOLD) {
+ d.clearTimer('holdtimeout');
+ d.switchTo(panStartedState, e, t);
+ }
+ },
+ touchend: function(d, e, t) {
+ // Ignore any touches but the initial one
+ if (t.identifier !== d.touch1)
+ return;
+
+ // If there was a previous tap that was close enough in time
+ // and space, then emit a 'dbltap' event
+ if (d.lastTap && isDoubleTap(d.lastTap, d.start)) {
+ d.emitEvent('tap', d.start);
+ d.emitEvent('dbltap', d.start);
+ // clear the lastTap property, so we don't get another one
+ d.lastTap = null;
+ }
+ else {
+ // Emit a 'tap' event using the starting coordinates
+ // as the event details
+ d.emitEvent('tap', d.start);
+
+ // Remember the coordinates of this tap so we can detect double taps
+ d.lastTap = coordinates(e, t);
+ }
+
+ // In either case clear the timer and go back to the initial state
+ d.clearTimer('holdtimeout');
+ d.switchTo(initialState);
+ },
+
+ holdtimeout: function(d) {
+ d.switchTo(holdState);
+ }
+
+ };
+
+ // A single touch has moved enough to exceed the pan threshold and now
+ // we're going to generate pan events after each move and a swipe event
+ // when the touch ends. We ignore any other touches that occur while this
+ // pan/swipe gesture is in progress.
+ var panStartedState = {
+ name: 'panStartedState',
+ init: function(d, e, t) {
+ // Panning doesn't start until the touch has moved more than a
+ // certain threshold. But we don't want the pan to have a jerky
+ // start where the first event is a big distance. So proceed as
+ // pan actually started at a point along the path between the
+ // first touch and this current touch.
+ d.start = d.last = between(d.start, coordinates(e, t));
+
+ // If we transition into this state with a touchmove event,
+ // then process it with that handler. If we don't do this then
+ // we can end up with swipe events that don't know their velocity
+ if (e.type === 'touchmove')
+ panStartedState.touchmove(d, e, t);
+ },
+
+ touchmove: function(d, e, t) {
+ // Ignore any fingers other than the one we're tracking
+ if (t.identifier !== d.touch1)
+ return;
+
+ // Each time the touch moves, emit a pan event but stay in this state
+ var current = coordinates(e, t);
+ d.emitEvent('pan', {
+ absolute: {
+ dx: current.screenX - d.start.screenX,
+ dy: current.screenY - d.start.screenY
+ },
+ relative: {
+ dx: current.screenX - d.last.screenX,
+ dy: current.screenY - d.last.screenY
+ },
+ position: current
+ });
+
+ // Track the pan velocity so we can report this with the swipe
+ // Use a exponential moving average for a bit of smoothing
+ // on the velocity
+ var dt = current.timeStamp - d.last.timeStamp;
+ var vx = (current.screenX - d.last.screenX) / dt;
+ var vy = (current.screenY - d.last.screenY) / dt;
+
+ if (d.vx == null) { // first time; no average
+ d.vx = vx;
+ d.vy = vy;
+ }
+ else {
+ d.vx = d.vx * GD.VELOCITY_SMOOTHING +
+ vx * (1 - GD.VELOCITY_SMOOTHING);
+ d.vy = d.vy * GD.VELOCITY_SMOOTHING +
+ vy * (1 - GD.VELOCITY_SMOOTHING);
+ }
+
+ d.last = current;
+ },
+ touchend: function(d, e, t) {
+ // Ignore any fingers other than the one we're tracking
+ if (t.identifier !== d.touch1)
+ return;
+
+ // Emit a swipe event when the finger goes up.
+ // Report start and end point, dx, dy, dt, velocity and direction
+ var current = coordinates(e, t);
+ var dx = current.screenX - d.start.screenX;
+ var dy = current.screenY - d.start.screenY;
+ // angle is a positive number of degrees, starting at 0 on the
+ // positive x axis and increasing clockwise.
+ var angle = atan2(dy, dx) * 180 / PI;
+ if (angle < 0)
+ angle += 360;
+
+ // Direction is 'right', 'down', 'left' or 'up'
+ var direction;
+ if (angle >= 315 || angle < 45)
+ direction = 'right';
+ else if (angle >= 45 && angle < 135)
+ direction = 'down';
+ else if (angle >= 135 && angle < 225)
+ direction = 'left';
+ else if (angle >= 225 && angle < 315)
+ direction = 'up';
+
+ d.emitEvent('swipe', {
+ start: d.start,
+ end: current,
+ dx: dx,
+ dy: dy,
+ dt: e.timeStamp - d.start.timeStamp,
+ vx: d.vx,
+ vy: d.vy,
+ direction: direction,
+ angle: angle
+ });
+
+ // Go back to the initial state
+ d.switchTo(initialState);
+ }
+ };
+
+ // We enter this state if the user touches and holds for long enough
+ // without moving much. When we enter we emit a holdstart event. Motion
+ // after the holdstart generates holdmove events. And when the touch ends
+ // we generate a holdend event. holdmove and holdend events can be used
+ // kind of like drag and drop events in a mouse-based UI. Currently,
+ // these events just report the coordinates of the touch. Do we need
+ // other details?
+ var holdState = {
+ name: 'holdState',
+ init: function(d) {
+ d.emitEvent('holdstart', d.start);
+ },
+
+ touchmove: function(d, e, t) {
+ var current = coordinates(e, t);
+ d.emitEvent('holdmove', {
+ absolute: {
+ dx: current.screenX - d.start.screenX,
+ dy: current.screenY - d.start.screenY
+ },
+ relative: {
+ dx: current.screenX - d.last.screenX,
+ dy: current.screenY - d.last.screenY
+ },
+ position: current
+ });
+
+ d.last = current;
+ },
+
+ touchend: function(d, e, t) {
+ var current = coordinates(e, t);
+ d.emitEvent('holdend', {
+ start: d.start,
+ end: current,
+ dx: current.screenX - d.start.screenX,
+ dy: current.screenY - d.start.screenY
+ });
+ d.switchTo(initialState);
+ }
+ };
+
+ // We enter this state if a second touch starts before we start
+ // recoginzing any other gesture. As the touches move we track the
+ // distance and angle between them to report scale and rotation values
+ // in transform events.
+ var transformState = {
+ name: 'transformState',
+ init: function(d, e, t) {
+ // Remember the id of the second touch
+ d.touch2 = t.identifier;
+
+ // Get the two Touch objects
+ var t1 = e.touches.identifiedTouch(d.touch1);
+ var t2 = e.touches.identifiedTouch(d.touch2);
+
+ // Compute and remember the initial distance and angle
+ d.startDistance = d.lastDistance = touchDistance(t1, t2);
+ d.startDirection = d.lastDirection = touchDirection(t1, t2);
+
+ // Don't start emitting events until we're past a threshold
+ d.scaled = d.rotated = false;
+ },
+
+ touchmove: function(d, e, t) {
+ // Ignore touches we're not tracking
+ if (t.identifier !== d.touch1 && t.identifier !== d.touch2)
+ return;
+
+ // Get the two Touch objects
+ var t1 = e.touches.identifiedTouch(d.touch1);
+ var t2 = e.touches.identifiedTouch(d.touch2);
+
+ // Compute the new midpoints, distance and direction
+ var midpoint = midpoints(e, t1, t2);
+ var distance = touchDistance(t1, t2);
+ var direction = touchDirection(t1, t2);
+ var rotation = touchRotation(d.startDirection, direction);
+
+ // Check all of these numbers against the thresholds. Otherwise
+ // the transforms are too jittery even when you try to hold your
+ // fingers still.
+ if (!d.scaled) {
+ if (abs(distance - d.startDistance) > GD.SCALE_THRESHOLD) {
+ d.scaled = true;
+ d.startDistance = d.lastDistance =
+ floor(d.startDistance +
+ GD.THRESHOLD_SMOOTHING * (distance - d.startDistance));
+ }
+ else
+ distance = d.startDistance;
+ }
+ if (!d.rotated) {
+ if (abs(rotation) > GD.ROTATE_THRESHOLD)
+ d.rotated = true;
+ else
+ direction = d.startDirection;
+ }
+
+ // If nothing has exceeded the threshold yet, then we
+ // don't even have to fire an event.
+ if (d.scaled || d.rotated) {
+ // The detail field for the transform gesture event includes
+ // 'absolute' transformations against the initial values and
+ // 'relative' transformations against the values from the last
+ // transformgesture event.
+ d.emitEvent('transform', {
+ absolute: { // transform details since gesture start
+ scale: distance / d.startDistance,
+ rotate: touchRotation(d.startDirection, direction)
+ },
+ relative: { // transform since last gesture change
+ scale: distance / d.lastDistance,
+ rotate: touchRotation(d.lastDirection, direction)
+ },
+ midpoint: midpoint
+ });
+
+ d.lastDistance = distance;
+ d.lastDirection = direction;
+ d.lastMidpoint = midpoint;
+ }
+ },
+
+ touchend: function(d, e, t) {
+ // If either finger goes up, we're done with the gesture.
+ // The user might move that finger and put it right back down
+ // again to begin another 2-finger gesture, so we can't go
+ // back to the initial state while one of the fingers remains up.
+ // On the other hand, we can't go back to touchStartedState because
+ // that would mean that the finger left down could cause a tap or
+ // pan event. So we need an afterTransform state that waits for
+ // a finger to come back down or the other finger to go up.
+ if (t.identifier === d.touch2)
+ d.touch2 = null;
+ else if (t.identifier === d.touch1) {
+ d.touch1 = d.touch2;
+ d.touch2 = null;
+ }
+ else
+ return; // It was a touch we weren't tracking
+
+ // If we emitted any transform events, now we need to emit
+ // a transformend event to end the series. The details of this
+ // event use the values from the last touchmove, and the
+ // relative amounts will 1 and 0, but they are included for
+ // completeness even though they are not useful.
+ if (d.scaled || d.rotated) {
+ d.emitEvent('transformend', {
+ absolute: { // transform details since gesture start
+ scale: d.lastDistance / d.startDistance,
+ rotate: touchRotation(d.startDirection, d.lastDirection)
+ },
+ relative: { // nothing has changed relative to the last touchmove
+ scale: 1,
+ rotate: 0
+ },
+ midpoint: d.lastMidpoint
+ });
+ }
+
+ d.switchTo(afterTransformState);
+ }
+ };
+
+ // We did a tranform and one finger went up. Wait for that finger to
+ // come back down or the other finger to go up too.
+ var afterTransformState = {
+ name: 'afterTransformState',
+ touchstart: function(d, e, t) {
+ d.switchTo(transformState, e, t);
+ },
+
+ touchend: function(d, e, t) {
+ if (t.identifier === d.touch1)
+ d.switchTo(initialState);
+ }
+ };
+
+ var mouseDownState = {
+ name: 'mouseDownState',
+ init: function(d, e) {
+ // Remember the target of the event
+ d.target = e.target;
+
+ // Register this detector as a *capturing* handler on the document
+ // so we get all subsequent mouse events until we remove these handlers
+ var doc = d.element.ownerDocument;
+ doc.addEventListener('mousemove', d, true);
+ doc.addEventListener('mouseup', d, true);
+
+ // Get the coordinates of the mouse event
+ d.start = d.last = mouseCoordinates(e);
+
+ // Start a timer for a hold
+ // If we're doing hold events, start a timer for them
+ if (d.options.holdEvents)
+ d.startTimer('holdtimeout', GD.HOLD_INTERVAL);
+ },
+
+ mousemove: function(d, e) {
+ // If the mouse has moved more than the panning threshold,
+ // then switch to the mouse panning state. Otherwise remain
+ // in this state
+
+ if (abs(e.screenX - d.start.screenX) > GD.MOUSE_PAN_THRESHOLD ||
+ abs(e.screenY - d.start.screenY) > GD.MOUSE_PAN_THRESHOLD) {
+ d.clearTimer('holdtimeout');
+ d.switchTo(mousePannedState, e);
+ }
+ },
+
+ mouseup: function(d, e) {
+ // Remove the capturing event handlers
+ var doc = d.element.ownerDocument;
+ doc.removeEventListener('mousemove', d, true);
+ doc.removeEventListener('mouseup', d, true);
+
+ // If there was a previous tap that was close enough in time
+ // and space, then emit a 'dbltap' event
+ if (d.lastTap && isDoubleTap(d.lastTap, d.start)) {
+ d.emitEvent('tap', d.start);
+ d.emitEvent('dbltap', d.start);
+ d.lastTap = null; // so we don't get another one
+ }
+ else {
+ // Emit a 'tap' event using the starting coordinates
+ // as the event details
+ d.emitEvent('tap', d.start);
+
+ // Remember the coordinates of this tap so we can detect double taps
+ d.lastTap = mouseCoordinates(e);
+ }
+
+ // In either case clear the timer and go back to the initial state
+ d.clearTimer('holdtimeout');
+ d.switchTo(initialState);
+ },
+
+ holdtimeout: function(d) {
+ d.switchTo(mouseHoldState);
+ }
+ };
+
+ // Like holdState, but for mouse events instead of touch events
+ var mouseHoldState = {
+ name: 'mouseHoldState',
+ init: function(d) {
+ d.emitEvent('holdstart', d.start);
+ },
+
+ mousemove: function(d, e) {
+ var current = mouseCoordinates(e);
+ d.emitEvent('holdmove', {
+ absolute: {
+ dx: current.screenX - d.start.screenX,
+ dy: current.screenY - d.start.screenY
+ },
+ relative: {
+ dx: current.screenX - d.last.screenX,
+ dy: current.screenY - d.last.screenY
+ },
+ position: current
+ });
+
+ d.last = current;
+ },
+
+ mouseup: function(d, e) {
+ var current = mouseCoordinates(e);
+ d.emitEvent('holdend', {
+ start: d.start,
+ end: current,
+ dx: current.screenX - d.start.screenX,
+ dy: current.screenY - d.start.screenY
+ });
+ d.switchTo(initialState);
+ }
+ };
+
+ var mousePannedState = {
+ name: 'mousePannedState',
+ init: function(d, e) {
+ // Panning doesn't start until the mouse has moved more than
+ // a certain threshold. But we don't want the pan to have a jerky
+ // start where the first event is a big distance. So reset the
+ // starting point to a point between the start point and this
+ // current point
+ d.start = d.last = between(d.start, mouseCoordinates(e));
+
+ // If we transition into this state with a mousemove event,
+ // then process it with that handler. If we don't do this then
+ // we can end up with swipe events that don't know their velocity
+ if (e.type === 'mousemove')
+ mousePannedState.mousemove(d, e);
+ },
+ mousemove: function(d, e) {
+ // Each time the mouse moves, emit a pan event but stay in this state
+ var current = mouseCoordinates(e);
+ d.emitEvent('pan', {
+ absolute: {
+ dx: current.screenX - d.start.screenX,
+ dy: current.screenY - d.start.screenY
+ },
+ relative: {
+ dx: current.screenX - d.last.screenX,
+ dy: current.screenY - d.last.screenY
+ },
+ position: current
+ });
+
+ // Track the pan velocity so we can report this with the swipe
+ // Use a exponential moving average for a bit of smoothing
+ // on the velocity
+ var dt = current.timeStamp - d.last.timeStamp;
+ var vx = (current.screenX - d.last.screenX) / dt;
+ var vy = (current.screenY - d.last.screenY) / dt;
+
+ if (d.vx == null) { // first time; no average
+ d.vx = vx;
+ d.vy = vy;
+ }
+ else {
+ d.vx = d.vx * GD.VELOCITY_SMOOTHING +
+ vx * (1 - GD.VELOCITY_SMOOTHING);
+ d.vy = d.vy * GD.VELOCITY_SMOOTHING +
+ vy * (1 - GD.VELOCITY_SMOOTHING);
+ }
+
+ d.last = current;
+ },
+ mouseup: function(d, e) {
+ // Remove the capturing event handlers
+ var doc = d.element.ownerDocument;
+ doc.removeEventListener('mousemove', d, true);
+ doc.removeEventListener('mouseup', d, true);
+
+ // Emit a swipe event when the mouse goes up.
+ // Report start and end point, dx, dy, dt, velocity and direction
+ var current = mouseCoordinates(e);
+
+ // FIXME:
+ // lots of code duplicated between this state and the corresponding
+ // touch state, can I combine them somehow?
+ var dx = current.screenX - d.start.screenX;
+ var dy = current.screenY - d.start.screenY;
+ // angle is a positive number of degrees, starting at 0 on the
+ // positive x axis and increasing clockwise.
+ var angle = atan2(dy, dx) * 180 / PI;
+ if (angle < 0)
+ angle += 360;
+
+ // Direction is 'right', 'down', 'left' or 'up'
+ var direction;
+ if (angle >= 315 || angle < 45)
+ direction = 'right';
+ else if (angle >= 45 && angle < 135)
+ direction = 'down';
+ else if (angle >= 135 && angle < 225)
+ direction = 'left';
+ else if (angle >= 225 && angle < 315)
+ direction = 'up';
+
+ d.emitEvent('swipe', {
+ start: d.start,
+ end: current,
+ dx: dx,
+ dy: dy,
+ dt: current.timeStamp - d.start.timeStamp,
+ vx: d.vx,
+ vy: d.vy,
+ direction: direction,
+ angle: angle
+ });
+
+ // Go back to the initial state
+ d.switchTo(initialState);
+ }
+ };
+
+ return GD;
+}());
+