• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright 2013 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
5cr.define('dnd', function() {
6  'use strict';
7
8  /** @const */ var BookmarkList = bmm.BookmarkList;
9  /** @const */ var ListItem = cr.ui.ListItem;
10  /** @const */ var TreeItem = cr.ui.TreeItem;
11
12  /**
13   * Enumeration of valid drop locations relative to an element. These are
14   * bit masks to allow combining multiple locations in a single value.
15   * @enum {number}
16   * @const
17   */
18  var DropPosition = {
19    NONE: 0,
20    ABOVE: 1,
21    ON: 2,
22    BELOW: 4
23  };
24
25  /**
26   * @type {Object} Drop information calculated in |handleDragOver|.
27   */
28  var dropDestination = null;
29
30  /**
31    * @type {number} Timer id used to help minimize flicker.
32    */
33  var removeDropIndicatorTimer;
34
35  /**
36    * The element currently targeted by a touch.
37    * @type {Element}
38    */
39  var currentTouchTarget;
40
41  /**
42    * The element that had a style applied it to indicate the drop location.
43    * This is used to easily remove the style when necessary.
44    * @type {Element}
45    */
46  var lastIndicatorElement;
47
48  /**
49    * The style that was applied to indicate the drop location.
50    * @type {string}
51    */
52  var lastIndicatorClassName;
53
54  var dropIndicator = {
55    /**
56     * Applies the drop indicator style on the target element and stores that
57     * information to easily remove the style in the future.
58     */
59    addDropIndicatorStyle: function(indicatorElement, position) {
60      var indicatorStyleName = position == DropPosition.ABOVE ? 'drag-above' :
61                               position == DropPosition.BELOW ? 'drag-below' :
62                               'drag-on';
63
64      lastIndicatorElement = indicatorElement;
65      lastIndicatorClassName = indicatorStyleName;
66
67      indicatorElement.classList.add(indicatorStyleName);
68    },
69
70    /**
71     * Clears the drop indicator style from the last element was the drop target
72     * so the drop indicator is no longer for that element.
73     */
74    removeDropIndicatorStyle: function() {
75      if (!lastIndicatorElement || !lastIndicatorClassName)
76        return;
77      lastIndicatorElement.classList.remove(lastIndicatorClassName);
78      lastIndicatorElement = null;
79      lastIndicatorClassName = null;
80    },
81
82    /**
83      * Displays the drop indicator on the current drop target to give the
84      * user feedback on where the drop will occur.
85      */
86    update: function(dropDest) {
87      window.clearTimeout(removeDropIndicatorTimer);
88
89      var indicatorElement = dropDest.element;
90      var position = dropDest.position;
91      if (dropDest.element instanceof BookmarkList) {
92        // For an empty bookmark list use 'drop-above' style.
93        position = DropPosition.ABOVE;
94      } else if (dropDest.element instanceof TreeItem) {
95        indicatorElement = indicatorElement.querySelector('.tree-row');
96      }
97      dropIndicator.removeDropIndicatorStyle();
98      dropIndicator.addDropIndicatorStyle(indicatorElement, position);
99    },
100
101    /**
102     * Stop displaying the drop indicator.
103     */
104    finish: function() {
105      // The use of a timeout is in order to reduce flickering as we move
106      // between valid drop targets.
107      window.clearTimeout(removeDropIndicatorTimer);
108      removeDropIndicatorTimer = window.setTimeout(function() {
109        dropIndicator.removeDropIndicatorStyle();
110      }, 100);
111    }
112  };
113
114  /**
115    * Delay for expanding folder when pointer hovers on folder in tree view in
116    * milliseconds.
117    * @type {number}
118    * @const
119    */
120  // TODO(yosin): EXPAND_FOLDER_DELAY should follow system settings. 400ms is
121  // taken from Windows default settings.
122  var EXPAND_FOLDER_DELAY = 400;
123
124  /**
125    * The timestamp when the mouse was over a folder during a drag operation.
126    * Used to open the hovered folder after a certain time.
127    * @type {number}
128    */
129  var lastHoverOnFolderTimeStamp = 0;
130
131  /**
132    * Expand a folder if the user has hovered for longer than the specified
133    * time during a drag action.
134    */
135  function updateAutoExpander(eventTimeStamp, overElement) {
136    // Expands a folder in tree view when pointer hovers on it longer than
137    // EXPAND_FOLDER_DELAY.
138    var hoverOnFolderTimeStamp = lastHoverOnFolderTimeStamp;
139    lastHoverOnFolderTimeStamp = 0;
140    if (hoverOnFolderTimeStamp) {
141      if (eventTimeStamp - hoverOnFolderTimeStamp >= EXPAND_FOLDER_DELAY)
142        overElement.expanded = true;
143      else
144        lastHoverOnFolderTimeStamp = hoverOnFolderTimeStamp;
145    } else if (overElement instanceof TreeItem &&
146                bmm.isFolder(overElement.bookmarkNode) &&
147                overElement.hasChildren &&
148                !overElement.expanded) {
149      lastHoverOnFolderTimeStamp = eventTimeStamp;
150    }
151  }
152
153  /**
154    * Stores the information about the bookmark and folders being dragged.
155    * @type {Object}
156    */
157  var dragData = null;
158  var dragInfo = {
159    handleChromeDragEnter: function(newDragData) {
160      dragData = newDragData;
161    },
162    clearDragData: function() {
163      dragData = null;
164    },
165    isDragValid: function() {
166      return !!dragData;
167    },
168    isSameProfile: function() {
169      return dragData && dragData.sameProfile;
170    },
171    isDraggingFolders: function() {
172      return dragData && dragData.elements.some(function(node) {
173        return !node.url;
174      });
175    },
176    isDraggingBookmark: function(bookmarkId) {
177      return dragData && dragData.elements.some(function(node) {
178        return node.id == bookmarkId;
179      });
180    },
181    isDraggingChildBookmark: function(folderId) {
182      return dragData && dragData.elements.some(function(node) {
183        return node.parentId == folderId;
184      });
185    },
186    isDraggingFolderToDescendant: function(bookmarkNode) {
187      return dragData && dragData.elements.some(function(node) {
188        var dragFolder = bmm.treeLookup[node.id];
189        var dragFolderNode = dragFolder && dragFolder.bookmarkNode;
190        return dragFolderNode && bmm.contains(dragFolderNode, bookmarkNode);
191      });
192    }
193  };
194
195  /**
196   * External function to select folders or bookmarks after a drop action.
197   * @type {function}
198   */
199  var selectItemsAfterUserAction = null;
200
201  function getBookmarkElement(el) {
202    while (el && !el.bookmarkNode) {
203      el = el.parentNode;
204    }
205    return el;
206  }
207
208  // If we are over the list and the list is showing search result, we cannot
209  // drop.
210  function isOverSearch(overElement) {
211    return list.isSearch() && list.contains(overElement);
212  }
213
214  /**
215   * Determines the valid drop positions for the given target element.
216   * @param {!HTMLElement} overElement The element that we are currently
217   *     dragging over.
218   * @return {DropPosition} An bit field enumeration of valid drop locations.
219   */
220  function calculateValidDropTargets(overElement) {
221    // Don't allow dropping if there is an ephemeral item being edited.
222    if (list.hasEphemeral())
223      return DropPosition.NONE;
224
225    if (!dragInfo.isDragValid() || isOverSearch(overElement))
226      return DropPosition.NONE;
227
228    if (dragInfo.isSameProfile() &&
229        (dragInfo.isDraggingBookmark(overElement.bookmarkNode.id) ||
230         dragInfo.isDraggingFolderToDescendant(overElement.bookmarkNode))) {
231      return DropPosition.NONE;
232    }
233
234    var canDropInfo = calculateDropAboveBelow(overElement);
235    if (canDropOn(overElement))
236      canDropInfo |= DropPosition.ON;
237
238    return canDropInfo;
239  }
240
241  function calculateDropAboveBelow(overElement) {
242    if (overElement instanceof BookmarkList)
243      return DropPosition.NONE;
244
245    // We cannot drop between Bookmarks bar and Other bookmarks.
246    if (overElement.bookmarkNode.parentId == bmm.ROOT_ID)
247      return DropPosition.NONE;
248
249    var isOverTreeItem = overElement instanceof TreeItem;
250    var isOverExpandedTree = isOverTreeItem && overElement.expanded;
251    var isDraggingFolders = dragInfo.isDraggingFolders();
252
253    // We can only drop between items in the tree if we have any folders.
254    if (isOverTreeItem && !isDraggingFolders)
255      return DropPosition.NONE;
256
257    // When dragging from a different profile we do not need to consider
258    // conflicts between the dragged items and the drop target.
259    if (!dragInfo.isSameProfile()) {
260      // Don't allow dropping below an expanded tree item since it is confusing
261      // to the user anyway.
262      return isOverExpandedTree ? DropPosition.ABOVE :
263                                  (DropPosition.ABOVE | DropPosition.BELOW);
264    }
265
266    var resultPositions = DropPosition.NONE;
267
268    // Cannot drop above if the item above is already in the drag source.
269    var previousElem = overElement.previousElementSibling;
270    if (!previousElem || !dragInfo.isDraggingBookmark(previousElem.bookmarkId))
271      resultPositions |= DropPosition.ABOVE;
272
273    // Don't allow dropping below an expanded tree item since it is confusing
274    // to the user anyway.
275    if (isOverExpandedTree)
276      return resultPositions;
277
278    // Cannot drop below if the item below is already in the drag source.
279    var nextElement = overElement.nextElementSibling;
280    if (!nextElement || !dragInfo.isDraggingBookmark(nextElement.bookmarkId))
281      resultPositions |= DropPosition.BELOW;
282
283    return resultPositions;
284  }
285
286  /**
287   * Determine whether we can drop the dragged items on the drop target.
288   * @param {!HTMLElement} overElement The element that we are currently
289   *     dragging over.
290   * @return {boolean} Whether we can drop the dragged items on the drop
291   *     target.
292   */
293  function canDropOn(overElement) {
294    // We can only drop on a folder.
295    if (!bmm.isFolder(overElement.bookmarkNode))
296      return false;
297
298    if (!dragInfo.isSameProfile())
299      return true;
300
301    if (overElement instanceof BookmarkList) {
302      // We are trying to drop an item past the last item. This is
303      // only allowed if dragged item is different from the last item
304      // in the list.
305      var listItems = list.items;
306      var len = listItems.length;
307      if (!len || !dragInfo.isDraggingBookmark(listItems[len - 1].bookmarkId))
308        return true;
309    }
310
311    return !dragInfo.isDraggingChildBookmark(overElement.bookmarkNode.id);
312  }
313
314  /**
315   * Callback for the dragstart event.
316   * @param {Event} e The dragstart event.
317   */
318  function handleDragStart(e) {
319    // Determine the selected bookmarks.
320    var target = e.target;
321    var draggedNodes = [];
322    var isFromTouch = target == currentTouchTarget;
323
324    if (target instanceof ListItem) {
325      // Use selected items.
326      draggedNodes = target.parentNode.selectedItems;
327    } else if (target instanceof TreeItem) {
328      draggedNodes.push(target.bookmarkNode);
329    }
330
331    // We manage starting the drag by using the extension API.
332    e.preventDefault();
333
334    // Do not allow dragging if there is an ephemeral item being edited at the
335    // moment.
336    if (list.hasEphemeral())
337      return;
338
339    if (draggedNodes.length) {
340      // If we are dragging a single link, we can do the *Link* effect.
341      // Otherwise, we only allow copy and move.
342      e.dataTransfer.effectAllowed = draggedNodes.length == 1 &&
343          !bmm.isFolder(draggedNodes[0]) ? 'copyMoveLink' : 'copyMove';
344
345      chrome.bookmarkManagerPrivate.startDrag(draggedNodes.map(function(node) {
346        return node.id;
347      }), isFromTouch);
348    }
349  }
350
351  function handleDragEnter(e) {
352    e.preventDefault();
353  }
354
355  /**
356   * Calback for the dragover event.
357   * @param {Event} e The dragover event.
358   */
359  function handleDragOver(e) {
360    // Allow DND on text inputs.
361    if (e.target.tagName != 'INPUT') {
362      // The default operation is to allow dropping links etc to do navigation.
363      // We never want to do that for the bookmark manager.
364      e.preventDefault();
365
366      // Set to none. This will get set to something if we can do the drop.
367      e.dataTransfer.dropEffect = 'none';
368    }
369
370    if (!dragInfo.isDragValid())
371      return;
372
373    var overElement = getBookmarkElement(e.target) ||
374                      (e.target == list ? list : null);
375    if (!overElement)
376      return;
377
378    updateAutoExpander(e.timeStamp, overElement);
379
380    var canDropInfo = calculateValidDropTargets(overElement);
381    if (canDropInfo == DropPosition.NONE)
382      return;
383
384    // Now we know that we can drop. Determine if we will drop above, on or
385    // below based on mouse position etc.
386
387    dropDestination = calcDropPosition(e.clientY, overElement, canDropInfo);
388    if (!dropDestination) {
389      e.dataTransfer.dropEffect = 'none';
390      return;
391    }
392
393    e.dataTransfer.dropEffect = dragInfo.isSameProfile() ? 'move' : 'copy';
394    dropIndicator.update(dropDestination);
395  }
396
397  /**
398   * This function determines where the drop will occur relative to the element.
399   * @return {?Object} If no valid drop position is found, null, otherwise
400   *     an object containing the following parameters:
401   *       element - The target element that will receive the drop.
402   *       position - A |DropPosition| relative to the |element|.
403   */
404  function calcDropPosition(elementClientY, overElement, canDropInfo) {
405    if (overElement instanceof BookmarkList) {
406      // Dropping on the BookmarkList either means dropping below the last
407      // bookmark element or on the list itself if it is empty.
408      var length = overElement.items.length;
409      if (length)
410        return {
411          element: overElement.getListItemByIndex(length - 1),
412          position: DropPosition.BELOW
413        };
414      return {element: overElement, position: DropPosition.ON};
415    }
416
417    var above = canDropInfo & DropPosition.ABOVE;
418    var below = canDropInfo & DropPosition.BELOW;
419    var on = canDropInfo & DropPosition.ON;
420    var rect = overElement.getBoundingClientRect();
421    var yRatio = (elementClientY - rect.top) / rect.height;
422
423    if (above && (yRatio <= .25 || yRatio <= .5 && (!below || !on)))
424      return {element: overElement, position: DropPosition.ABOVE};
425    if (below && (yRatio > .75 || yRatio > .5 && (!above || !on)))
426      return {element: overElement, position: DropPosition.BELOW};
427    if (on)
428      return {element: overElement, position: DropPosition.ON};
429    return null;
430  }
431
432  function calculateDropInfo(eventTarget, dropDestination) {
433    if (!dropDestination || !dragInfo.isDragValid())
434      return null;
435
436    var dropPos = dropDestination.position;
437    var relatedNode = dropDestination.element.bookmarkNode;
438    var dropInfoResult = {
439        selectTarget: null,
440        selectedTreeId: -1,
441        parentId: dropPos == DropPosition.ON ? relatedNode.id :
442                                               relatedNode.parentId,
443        index: -1,
444        relatedIndex: -1
445      };
446
447    // Try to find the index in the dataModel so we don't have to always keep
448    // the index for the list items up to date.
449    var overElement = getBookmarkElement(eventTarget);
450    if (overElement instanceof ListItem) {
451      dropInfoResult.relatedIndex =
452          overElement.parentNode.dataModel.indexOf(relatedNode);
453      dropInfoResult.selectTarget = list;
454    } else if (overElement instanceof BookmarkList) {
455      dropInfoResult.relatedIndex = overElement.dataModel.length - 1;
456      dropInfoResult.selectTarget = list;
457    } else {
458      // Tree
459      dropInfoResult.relatedIndex = relatedNode.index;
460      dropInfoResult.selectTarget = tree;
461      dropInfoResult.selectedTreeId =
462          tree.selectedItem ? tree.selectedItem.bookmarkId : null;
463    }
464
465    if (dropPos == DropPosition.ABOVE)
466      dropInfoResult.index = dropInfoResult.relatedIndex;
467    else if (dropPos == DropPosition.BELOW)
468      dropInfoResult.index = dropInfoResult.relatedIndex + 1;
469
470    return dropInfoResult;
471  }
472
473  function handleDragLeave(e) {
474    dropIndicator.finish();
475  }
476
477  function handleDrop(e) {
478    var dropInfo = calculateDropInfo(e.target, dropDestination);
479    if (dropInfo) {
480      selectItemsAfterUserAction(dropInfo.selectTarget,
481                                 dropInfo.selectedTreeId);
482      if (dropInfo.index != -1)
483        chrome.bookmarkManagerPrivate.drop(dropInfo.parentId, dropInfo.index);
484      else
485        chrome.bookmarkManagerPrivate.drop(dropInfo.parentId);
486
487      e.preventDefault();
488    }
489    dropDestination = null;
490    dropIndicator.finish();
491  }
492
493  function setCurrentTouchTarget(e) {
494    // Only set a new target for a single touch point.
495    if (e.touches.length == 1)
496      currentTouchTarget = getBookmarkElement(e.target);
497  }
498
499  function clearCurrentTouchTarget(e) {
500    if (getBookmarkElement(e.target) == currentTouchTarget)
501      currentTouchTarget = null;
502  }
503
504  function clearDragData() {
505    dragInfo.clearDragData();
506    dropDestination = null;
507  }
508
509  function init(selectItemsAfterUserActionFunction) {
510    function deferredClearData() {
511      setTimeout(clearDragData);
512    }
513
514    selectItemsAfterUserAction = selectItemsAfterUserActionFunction;
515
516    document.addEventListener('dragstart', handleDragStart);
517    document.addEventListener('dragenter', handleDragEnter);
518    document.addEventListener('dragover', handleDragOver);
519    document.addEventListener('dragleave', handleDragLeave);
520    document.addEventListener('drop', handleDrop);
521    document.addEventListener('dragend', deferredClearData);
522    document.addEventListener('mouseup', deferredClearData);
523    document.addEventListener('mousedown', clearCurrentTouchTarget);
524    document.addEventListener('touchcancel', clearCurrentTouchTarget);
525    document.addEventListener('touchend', clearCurrentTouchTarget);
526    document.addEventListener('touchstart', setCurrentTouchTarget);
527
528    chrome.bookmarkManagerPrivate.onDragEnter.addListener(
529        dragInfo.handleChromeDragEnter);
530    chrome.bookmarkManagerPrivate.onDragLeave.addListener(deferredClearData);
531    chrome.bookmarkManagerPrivate.onDrop.addListener(deferredClearData);
532  }
533  return {init: init};
534});
535