Javascript  |  384行  |  12.08 KB

// Copyright (c) 2011 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview Grabber implementation.
 * Allows you to pick up objects (with a long-press) and drag them around the
 * screen.
 *
 * Note: This should perhaps really use standard drag-and-drop events, but there
 * is no standard for them on touch devices.  We could define a model for
 * activating touch-based dragging of elements (programatically and/or with
 * CSS attributes) and use it here (even have a JS library to generate such
 * events when the browser doesn't support them).
 */

// Use an anonymous function to enable strict mode just for this file (which
// will be concatenated with other files when embedded in Chrome)
var Grabber = (function() {
  'use strict';

  /**
   * Create a Grabber object to enable grabbing and dragging a given element.
   * @constructor
   * @param {!Element} element The element that can be grabbed and moved.
   */
  function Grabber(element) {
    /**
     * The element the grabber is attached to.
     * @type {!Element}
     * @private
     */
    this.element_ = element;

    /**
     * The TouchHandler responsible for firing lower-level touch events when the
     * element is manipulated.
     * @type {!TouchHandler}
     * @private
     */
    this.touchHandler_ = new TouchHandler(this.element);

    /**
     * Tracks all event listeners we have created.
     * @type {EventTracker}
     * @private
     */
    this.events_ = new EventTracker();

    // Enable the generation of events when the element is touched (but no need
    // to use the early capture phase of event processing).
    this.touchHandler_.enable(/* opt_capture */ false);

    // Prevent any built-in drag-and-drop support from activating for the
    // element. Note that we don't want details of how we're implementing
    // dragging here to leak out of this file (eg. we may switch to using webkit
    // drag-and-drop).
    this.events_.add(this.element, 'dragstart', function(e) {
      e.preventDefault();
    }, true);

    // Add our TouchHandler event listeners
    this.events_.add(this.element, TouchHandler.EventType.TOUCH_START,
        this.onTouchStart_.bind(this), false);
    this.events_.add(this.element, TouchHandler.EventType.LONG_PRESS,
        this.onLongPress_.bind(this), false);
    this.events_.add(this.element, TouchHandler.EventType.DRAG_START,
        this.onDragStart_.bind(this), false);
    this.events_.add(this.element, TouchHandler.EventType.DRAG_MOVE,
        this.onDragMove_.bind(this), false);
    this.events_.add(this.element, TouchHandler.EventType.DRAG_END,
        this.onDragEnd_.bind(this), false);
    this.events_.add(this.element, TouchHandler.EventType.TOUCH_END,
        this.onTouchEnd_.bind(this), false);
  }

  /**
   * Events fired by the grabber.
   * Events are fired at the element affected (not the element being dragged).
   * @enum {string}
   */
  Grabber.EventType = {
    // Fired at the grabber element when it is first grabbed
    GRAB: 'grabber:grab',
    // Fired at the grabber element when dragging begins (after GRAB)
    DRAG_START: 'grabber:dragstart',
    // Fired at an element when something is dragged over top of it.
    DRAG_ENTER: 'grabber:dragenter',
    // Fired at an element when something is no longer over top of it.
    // Not fired at all in the case of a DROP
    DRAG_LEAVE: 'grabber:drag',
    // Fired at an element when something is dropped on top of it.
    DROP: 'grabber:drop',
    // Fired at the grabber element when dragging ends (successfully or not) -
    // after any DROP or DRAG_LEAVE
    DRAG_END: 'grabber:dragend',
    // Fired at the grabber element when it is released (even if no drag
    // occured) - after any DRAG_END event.
    RELEASE: 'grabber:release'
  };

  /**
   * The type of Event sent by Grabber
   * @constructor
   * @param {string} type The type of event (one of Grabber.EventType).
   * @param {Element!} grabbedElement The element being dragged.
   */
  Grabber.Event = function(type, grabbedElement) {
    var event = document.createEvent('Event');
    event.initEvent(type, true, true);
    event.__proto__ = Grabber.Event.prototype;

    /**
     * The element which is being dragged.  For some events this will be the
     * same as 'target', but for events like DROP that are fired at another
     * element it will be different.
     * @type {!Element}
     */
    event.grabbedElement = grabbedElement;

    return event;
  };

  Grabber.Event.prototype = {
    __proto__: Event.prototype
  };


  /**
   * The CSS class to apply when an element is touched but not yet
   * grabbed.
   * @type {string}
   */
  Grabber.PRESSED_CLASS = 'grabber-pressed';

  /**
   * The class to apply when an element has been held (including when it is
   * being dragged.
   * @type {string}
   */
  Grabber.GRAB_CLASS = 'grabber-grabbed';

  /**
   * The class to apply when a grabbed element is being dragged.
   * @type {string}
   */
  Grabber.DRAGGING_CLASS = 'grabber-dragging';

  Grabber.prototype = {
    /**
     * @return {!Element} The element that can be grabbed.
     */
    get element() {
      return this.element_;
    },

    /**
     * Clean up all event handlers (eg. if the underlying element will be
     * removed)
     */
    dispose: function() {
      this.touchHandler_.disable();
      this.events_.removeAll();

      // Clean-up any active touch/drag
      if (this.dragging_)
        this.stopDragging_();
      this.onTouchEnd_();
    },

    /**
     * Invoked whenever this element is first touched
     * @param {!TouchHandler.Event} e The TouchHandler event.
     * @private
     */
    onTouchStart_: function(e) {
      this.element.classList.add(Grabber.PRESSED_CLASS);

      // Always permit the touch to perhaps trigger a drag
      e.enableDrag = true;
    },

    /**
     * Invoked whenever the element stops being touched.
     * Can be called explicitly to cleanup any active touch.
     * @param {!TouchHandler.Event=} opt_e The TouchHandler event.
     * @private
     */
    onTouchEnd_: function(opt_e) {
      if (this.grabbed_) {
        // Mark this element as no longer being grabbed
        this.element.classList.remove(Grabber.GRAB_CLASS);
        this.element.style.pointerEvents = '';
        this.grabbed_ = false;

        this.sendEvent_(Grabber.EventType.RELEASE, this.element);
      } else {
        this.element.classList.remove(Grabber.PRESSED_CLASS);
      }
    },

    /**
     * Handler for TouchHandler's LONG_PRESS event
     * Invoked when the element is held (without being dragged)
     * @param {!TouchHandler.Event} e The TouchHandler event.
     * @private
     */
    onLongPress_: function(e) {
      assert(!this.grabbed_, 'Got longPress while still being held');

      this.element.classList.remove(Grabber.PRESSED_CLASS);
      this.element.classList.add(Grabber.GRAB_CLASS);

      // Disable mouse events from the element - we care only about what's
      // under the element after it's grabbed (since we're getting move events
      // from the body - not the element itself).  Note that we can't wait until
      // onDragStart to do this because it won't have taken effect by the first
      // onDragMove.
      this.element.style.pointerEvents = 'none';

      this.grabbed_ = true;

      this.sendEvent_(Grabber.EventType.GRAB, this.element);
    },

    /**
     * Invoked when the element is dragged.
     * @param {!TouchHandler.Event} e The TouchHandler event.
     * @private
     */
    onDragStart_: function(e) {
      assert(!this.lastEnter_, 'only expect one drag to occur at a time');
      assert(!this.dragging_);

      // We only want to drag the element if its been grabbed
      if (this.grabbed_) {
        // Mark the item as being dragged
        // Ensures our translate transform won't be animated and cancels any
        // outstanding animations.
        this.element.classList.add(Grabber.DRAGGING_CLASS);

        // Determine the webkitTransform currently applied to the element.
        // Note that it's important that we do this AFTER cancelling animation,
        // otherwise we could see an intermediate value.
        // We'll assume this value will be constant for the duration of the drag
        // so that we can combine it with our translate3d transform.
        this.baseTransform_ = this.element.ownerDocument.defaultView.
            getComputedStyle(this.element).webkitTransform;

        this.sendEvent_(Grabber.EventType.DRAG_START, this.element);
        e.enableDrag = true;
        this.dragging_ = true;

      } else {
        // Hasn't been grabbed - don't drag, just unpress
        this.element.classList.remove(Grabber.PRESSED_CLASS);
        e.enableDrag = false;
      }
    },

    /**
     * Invoked when a grabbed element is being dragged
     * @param {!TouchHandler.Event} e The TouchHandler event.
     * @private
     */
    onDragMove_: function(e) {
      assert(this.grabbed_ && this.dragging_);

      this.translateTo_(e.dragDeltaX, e.dragDeltaY);

      var target = e.touchedElement;
      if (target && target != this.lastEnter_) {
        // Send the events
        this.sendDragLeave_(e);
        this.sendEvent_(Grabber.EventType.DRAG_ENTER, target);
      }
      this.lastEnter_ = target;
    },

    /**
     * Send DRAG_LEAVE to the element last sent a DRAG_ENTER if any.
     * @param {!TouchHandler.Event} e The event triggering this DRAG_LEAVE.
     * @private
     */
    sendDragLeave_: function(e) {
      if (this.lastEnter_) {
        this.sendEvent_(Grabber.EventType.DRAG_LEAVE, this.lastEnter_);
        this.lastEnter_ = undefined;
      }
    },

    /**
     * Moves the element to the specified position.
     * @param {number} x Horizontal position to move to.
     * @param {number} y Vertical position to move to.
     * @private
     */
    translateTo_: function(x, y) {
      // Order is important here - we want to translate before doing the zoom
      this.element.style.WebkitTransform = 'translate3d(' + x + 'px, ' +
          y + 'px, 0) ' + this.baseTransform_;
    },

    /**
     * Invoked when the element is no longer being dragged.
     * @param {TouchHandler.Event} e The TouchHandler event.
     * @private
     */
    onDragEnd_: function(e) {
      // We should get this before the onTouchEnd.  Don't change
      // this.grabbed_ - it's onTouchEnd's responsibility to clear it.
      assert(this.grabbed_ && this.dragging_);
      var event;

      // Send the drop event to the element underneath the one we're dragging.
      var target = e.touchedElement;
      if (target)
        this.sendEvent_(Grabber.EventType.DROP, target);

      // Cleanup and send DRAG_END
      // Note that like HTML5 DND, we don't send DRAG_LEAVE on drop
      this.stopDragging_();
    },

    /**
     * Clean-up the active drag and send DRAG_LEAVE
     * @private
     */
    stopDragging_: function() {
      assert(this.dragging_);
      this.lastEnter_ = undefined;

      // Mark the element as no longer being dragged
      this.element.classList.remove(Grabber.DRAGGING_CLASS);
      this.element.style.webkitTransform = '';

      this.dragging_ = false;
      this.sendEvent_(Grabber.EventType.DRAG_END, this.element);
    },

    /**
     * Send a Grabber event to a specific element
     * @param {string} eventType The type of event to send.
     * @param {!Element} target The element to send the event to.
     * @private
     */
    sendEvent_: function(eventType, target) {
      var event = new Grabber.Event(eventType, this.element);
      target.dispatchEvent(event);
    },

    /**
     * Whether or not the element is currently grabbed.
     * @type {boolean}
     * @private
     */
    grabbed_: false,

    /**
     * Whether or not the element is currently being dragged.
     * @type {boolean}
     * @private
     */
    dragging_: false,

    /**
     * The webkitTransform applied to the element when it first started being
     * dragged.
     * @type {string|undefined}
     * @private
     */
    baseTransform_: undefined,

    /**
     * The element for which a DRAG_ENTER event was last fired
     * @type {Element|undefined}
     * @private
     */
    lastEnter_: undefined
  };

  return Grabber;
})();