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