1// Copyright (c) 2011 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/**
6 * @fileoverview Grabber implementation.
7 * Allows you to pick up objects (with a long-press) and drag them around the
8 * screen.
9 *
10 * Note: This should perhaps really use standard drag-and-drop events, but there
11 * is no standard for them on touch devices.  We could define a model for
12 * activating touch-based dragging of elements (programatically and/or with
13 * CSS attributes) and use it here (even have a JS library to generate such
14 * events when the browser doesn't support them).
15 */
16
17// Use an anonymous function to enable strict mode just for this file (which
18// will be concatenated with other files when embedded in Chrome)
19var Grabber = (function() {
20  'use strict';
21
22  /**
23   * Create a Grabber object to enable grabbing and dragging a given element.
24   * @constructor
25   * @param {!Element} element The element that can be grabbed and moved.
26   */
27  function Grabber(element) {
28    /**
29     * The element the grabber is attached to.
30     * @type {!Element}
31     * @private
32     */
33    this.element_ = element;
34
35    /**
36     * The TouchHandler responsible for firing lower-level touch events when the
37     * element is manipulated.
38     * @type {!TouchHandler}
39     * @private
40     */
41    this.touchHandler_ = new TouchHandler(this.element);
42
43    /**
44     * Tracks all event listeners we have created.
45     * @type {EventTracker}
46     * @private
47     */
48    this.events_ = new EventTracker();
49
50    // Enable the generation of events when the element is touched (but no need
51    // to use the early capture phase of event processing).
52    this.touchHandler_.enable(/* opt_capture */ false);
53
54    // Prevent any built-in drag-and-drop support from activating for the
55    // element. Note that we don't want details of how we're implementing
56    // dragging here to leak out of this file (eg. we may switch to using webkit
57    // drag-and-drop).
58    this.events_.add(this.element, 'dragstart', function(e) {
59      e.preventDefault();
60    }, true);
61
62    // Add our TouchHandler event listeners
63    this.events_.add(this.element, TouchHandler.EventType.TOUCH_START,
64        this.onTouchStart_.bind(this), false);
65    this.events_.add(this.element, TouchHandler.EventType.LONG_PRESS,
66        this.onLongPress_.bind(this), false);
67    this.events_.add(this.element, TouchHandler.EventType.DRAG_START,
68        this.onDragStart_.bind(this), false);
69    this.events_.add(this.element, TouchHandler.EventType.DRAG_MOVE,
70        this.onDragMove_.bind(this), false);
71    this.events_.add(this.element, TouchHandler.EventType.DRAG_END,
72        this.onDragEnd_.bind(this), false);
73    this.events_.add(this.element, TouchHandler.EventType.TOUCH_END,
74        this.onTouchEnd_.bind(this), false);
75  }
76
77  /**
78   * Events fired by the grabber.
79   * Events are fired at the element affected (not the element being dragged).
80   * @enum {string}
81   */
82  Grabber.EventType = {
83    // Fired at the grabber element when it is first grabbed
84    GRAB: 'grabber:grab',
85    // Fired at the grabber element when dragging begins (after GRAB)
86    DRAG_START: 'grabber:dragstart',
87    // Fired at an element when something is dragged over top of it.
88    DRAG_ENTER: 'grabber:dragenter',
89    // Fired at an element when something is no longer over top of it.
90    // Not fired at all in the case of a DROP
91    DRAG_LEAVE: 'grabber:drag',
92    // Fired at an element when something is dropped on top of it.
93    DROP: 'grabber:drop',
94    // Fired at the grabber element when dragging ends (successfully or not) -
95    // after any DROP or DRAG_LEAVE
96    DRAG_END: 'grabber:dragend',
97    // Fired at the grabber element when it is released (even if no drag
98    // occured) - after any DRAG_END event.
99    RELEASE: 'grabber:release'
100  };
101
102  /**
103   * The type of Event sent by Grabber
104   * @constructor
105   * @param {string} type The type of event (one of Grabber.EventType).
106   * @param {Element!} grabbedElement The element being dragged.
107   */
108  Grabber.Event = function(type, grabbedElement) {
109    var event = document.createEvent('Event');
110    event.initEvent(type, true, true);
111    event.__proto__ = Grabber.Event.prototype;
112
113    /**
114     * The element which is being dragged.  For some events this will be the
115     * same as 'target', but for events like DROP that are fired at another
116     * element it will be different.
117     * @type {!Element}
118     */
119    event.grabbedElement = grabbedElement;
120
121    return event;
122  };
123
124  Grabber.Event.prototype = {
125    __proto__: Event.prototype
126  };
127
128
129  /**
130   * The CSS class to apply when an element is touched but not yet
131   * grabbed.
132   * @type {string}
133   */
134  Grabber.PRESSED_CLASS = 'grabber-pressed';
135
136  /**
137   * The class to apply when an element has been held (including when it is
138   * being dragged.
139   * @type {string}
140   */
141  Grabber.GRAB_CLASS = 'grabber-grabbed';
142
143  /**
144   * The class to apply when a grabbed element is being dragged.
145   * @type {string}
146   */
147  Grabber.DRAGGING_CLASS = 'grabber-dragging';
148
149  Grabber.prototype = {
150    /**
151     * @return {!Element} The element that can be grabbed.
152     */
153    get element() {
154      return this.element_;
155    },
156
157    /**
158     * Clean up all event handlers (eg. if the underlying element will be
159     * removed)
160     */
161    dispose: function() {
162      this.touchHandler_.disable();
163      this.events_.removeAll();
164
165      // Clean-up any active touch/drag
166      if (this.dragging_)
167        this.stopDragging_();
168      this.onTouchEnd_();
169    },
170
171    /**
172     * Invoked whenever this element is first touched
173     * @param {!TouchHandler.Event} e The TouchHandler event.
174     * @private
175     */
176    onTouchStart_: function(e) {
177      this.element.classList.add(Grabber.PRESSED_CLASS);
178
179      // Always permit the touch to perhaps trigger a drag
180      e.enableDrag = true;
181    },
182
183    /**
184     * Invoked whenever the element stops being touched.
185     * Can be called explicitly to cleanup any active touch.
186     * @param {!TouchHandler.Event=} opt_e The TouchHandler event.
187     * @private
188     */
189    onTouchEnd_: function(opt_e) {
190      if (this.grabbed_) {
191        // Mark this element as no longer being grabbed
192        this.element.classList.remove(Grabber.GRAB_CLASS);
193        this.element.style.pointerEvents = '';
194        this.grabbed_ = false;
195
196        this.sendEvent_(Grabber.EventType.RELEASE, this.element);
197      } else {
198        this.element.classList.remove(Grabber.PRESSED_CLASS);
199      }
200    },
201
202    /**
203     * Handler for TouchHandler's LONG_PRESS event
204     * Invoked when the element is held (without being dragged)
205     * @param {!TouchHandler.Event} e The TouchHandler event.
206     * @private
207     */
208    onLongPress_: function(e) {
209      assert(!this.grabbed_, 'Got longPress while still being held');
210
211      this.element.classList.remove(Grabber.PRESSED_CLASS);
212      this.element.classList.add(Grabber.GRAB_CLASS);
213
214      // Disable mouse events from the element - we care only about what's
215      // under the element after it's grabbed (since we're getting move events
216      // from the body - not the element itself).  Note that we can't wait until
217      // onDragStart to do this because it won't have taken effect by the first
218      // onDragMove.
219      this.element.style.pointerEvents = 'none';
220
221      this.grabbed_ = true;
222
223      this.sendEvent_(Grabber.EventType.GRAB, this.element);
224    },
225
226    /**
227     * Invoked when the element is dragged.
228     * @param {!TouchHandler.Event} e The TouchHandler event.
229     * @private
230     */
231    onDragStart_: function(e) {
232      assert(!this.lastEnter_, 'only expect one drag to occur at a time');
233      assert(!this.dragging_);
234
235      // We only want to drag the element if its been grabbed
236      if (this.grabbed_) {
237        // Mark the item as being dragged
238        // Ensures our translate transform won't be animated and cancels any
239        // outstanding animations.
240        this.element.classList.add(Grabber.DRAGGING_CLASS);
241
242        // Determine the webkitTransform currently applied to the element.
243        // Note that it's important that we do this AFTER cancelling animation,
244        // otherwise we could see an intermediate value.
245        // We'll assume this value will be constant for the duration of the drag
246        // so that we can combine it with our translate3d transform.
247        this.baseTransform_ = this.element.ownerDocument.defaultView.
248            getComputedStyle(this.element).webkitTransform;
249
250        this.sendEvent_(Grabber.EventType.DRAG_START, this.element);
251        e.enableDrag = true;
252        this.dragging_ = true;
253
254      } else {
255        // Hasn't been grabbed - don't drag, just unpress
256        this.element.classList.remove(Grabber.PRESSED_CLASS);
257        e.enableDrag = false;
258      }
259    },
260
261    /**
262     * Invoked when a grabbed element is being dragged
263     * @param {!TouchHandler.Event} e The TouchHandler event.
264     * @private
265     */
266    onDragMove_: function(e) {
267      assert(this.grabbed_ && this.dragging_);
268
269      this.translateTo_(e.dragDeltaX, e.dragDeltaY);
270
271      var target = e.touchedElement;
272      if (target && target != this.lastEnter_) {
273        // Send the events
274        this.sendDragLeave_(e);
275        this.sendEvent_(Grabber.EventType.DRAG_ENTER, target);
276      }
277      this.lastEnter_ = target;
278    },
279
280    /**
281     * Send DRAG_LEAVE to the element last sent a DRAG_ENTER if any.
282     * @param {!TouchHandler.Event} e The event triggering this DRAG_LEAVE.
283     * @private
284     */
285    sendDragLeave_: function(e) {
286      if (this.lastEnter_) {
287        this.sendEvent_(Grabber.EventType.DRAG_LEAVE, this.lastEnter_);
288        this.lastEnter_ = undefined;
289      }
290    },
291
292    /**
293     * Moves the element to the specified position.
294     * @param {number} x Horizontal position to move to.
295     * @param {number} y Vertical position to move to.
296     * @private
297     */
298    translateTo_: function(x, y) {
299      // Order is important here - we want to translate before doing the zoom
300      this.element.style.WebkitTransform = 'translate3d(' + x + 'px, ' +
301          y + 'px, 0) ' + this.baseTransform_;
302    },
303
304    /**
305     * Invoked when the element is no longer being dragged.
306     * @param {TouchHandler.Event} e The TouchHandler event.
307     * @private
308     */
309    onDragEnd_: function(e) {
310      // We should get this before the onTouchEnd.  Don't change
311      // this.grabbed_ - it's onTouchEnd's responsibility to clear it.
312      assert(this.grabbed_ && this.dragging_);
313      var event;
314
315      // Send the drop event to the element underneath the one we're dragging.
316      var target = e.touchedElement;
317      if (target)
318        this.sendEvent_(Grabber.EventType.DROP, target);
319
320      // Cleanup and send DRAG_END
321      // Note that like HTML5 DND, we don't send DRAG_LEAVE on drop
322      this.stopDragging_();
323    },
324
325    /**
326     * Clean-up the active drag and send DRAG_LEAVE
327     * @private
328     */
329    stopDragging_: function() {
330      assert(this.dragging_);
331      this.lastEnter_ = undefined;
332
333      // Mark the element as no longer being dragged
334      this.element.classList.remove(Grabber.DRAGGING_CLASS);
335      this.element.style.webkitTransform = '';
336
337      this.dragging_ = false;
338      this.sendEvent_(Grabber.EventType.DRAG_END, this.element);
339    },
340
341    /**
342     * Send a Grabber event to a specific element
343     * @param {string} eventType The type of event to send.
344     * @param {!Element} target The element to send the event to.
345     * @private
346     */
347    sendEvent_: function(eventType, target) {
348      var event = new Grabber.Event(eventType, this.element);
349      target.dispatchEvent(event);
350    },
351
352    /**
353     * Whether or not the element is currently grabbed.
354     * @type {boolean}
355     * @private
356     */
357    grabbed_: false,
358
359    /**
360     * Whether or not the element is currently being dragged.
361     * @type {boolean}
362     * @private
363     */
364    dragging_: false,
365
366    /**
367     * The webkitTransform applied to the element when it first started being
368     * dragged.
369     * @type {string|undefined}
370     * @private
371     */
372    baseTransform_: undefined,
373
374    /**
375     * The element for which a DRAG_ENTER event was last fired
376     * @type {Element|undefined}
377     * @private
378     */
379    lastEnter_: undefined
380  };
381
382  return Grabber;
383})();
384