Home | History | Annotate | Download | only in js
      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 
      5 cr.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 abou 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     if (draggedNodes.length) {
    323       // If we are dragging a single link, we can do the *Link* effect.
    324       // Otherwise, we only allow copy and move.
    325       e.dataTransfer.effectAllowed = draggedNodes.length == 1 &&
    326           !bmm.isFolder(draggedNodes[0]) ? 'copyMoveLink' : 'copyMove';
    327 
    328       chrome.bookmarkManagerPrivate.startDrag(draggedNodes.map(function(node) {
    329         return node.id;
    330       }));
    331     }
    332   }
    333 
    334   function handleDragEnter(e) {
    335     e.preventDefault();
    336   }
    337 
    338   /**
    339    * Calback for the dragover event.
    340    * @param {Event} e The dragover event.
    341    */
    342   function handleDragOver(e) {
    343     // Allow DND on text inputs.
    344     if (e.target.tagName != 'INPUT') {
    345       // The default operation is to allow dropping links etc to do navigation.
    346       // We never want to do that for the bookmark manager.
    347       e.preventDefault();
    348 
    349       // Set to none. This will get set to something if we can do the drop.
    350       e.dataTransfer.dropEffect = 'none';
    351     }
    352 
    353     if (!dragInfo.isDragValid())
    354       return;
    355 
    356     var overElement = getBookmarkElement(e.target) ||
    357                       (e.target == list ? list : null);
    358     if (!overElement)
    359       return;
    360 
    361     updateAutoExpander(e.timeStamp, overElement);
    362 
    363     var canDropInfo = calculateValidDropTargets(overElement);
    364     if (canDropInfo == DropPosition.NONE)
    365       return;
    366 
    367     // Now we know that we can drop. Determine if we will drop above, on or
    368     // below based on mouse position etc.
    369 
    370     dropDestination = calcDropPosition(e.clientY, overElement, canDropInfo);
    371     if (!dropDestination) {
    372       e.dataTransfer.dropEffect = 'none';
    373       return;
    374     }
    375 
    376     e.dataTransfer.dropEffect = dragInfo.isSameProfile() ? 'move' : 'copy';
    377     dropIndicator.update(dropDestination);
    378   }
    379 
    380   /**
    381    * This function determines where the drop will occur relative to the element.
    382    * @return {?Object} If no valid drop position is found, null, otherwise
    383    *     an object containing the following parameters:
    384    *       element - The target element that will receive the drop.
    385    *       position - A |DropPosition| relative to the |element|.
    386    */
    387   function calcDropPosition(elementClientY, overElement, canDropInfo) {
    388     if (overElement instanceof BookmarkList) {
    389       // Dropping on the BookmarkList either means dropping below the last
    390       // bookmark element or on the list itself if it is empty.
    391       var length = overElement.items.length;
    392       if (length)
    393         return {
    394           element: overElement.getListItemByIndex(length - 1),
    395           position: DropPosition.BELOW
    396         };
    397       return {element: overElement, position: DropPosition.ON};
    398     }
    399 
    400     var above = canDropInfo & DropPosition.ABOVE;
    401     var below = canDropInfo & DropPosition.BELOW;
    402     var on = canDropInfo & DropPosition.ON;
    403     var rect = overElement.getBoundingClientRect();
    404     var yRatio = (elementClientY - rect.top) / rect.height;
    405 
    406     if (above && (yRatio <= .25 || yRatio <= .5 && (!below || !on)))
    407       return {element: overElement, position: DropPosition.ABOVE};
    408     if (below && (yRatio > .75 || yRatio > .5 && (!above || !on)))
    409       return {element: overElement, position: DropPosition.BELOW};
    410     if (on)
    411       return {element: overElement, position: DropPosition.ON};
    412     return null;
    413   }
    414 
    415   function calculateDropInfo(eventTarget, dropDestination) {
    416     if (!dropDestination || !dragInfo.isDragValid())
    417       return null;
    418 
    419     var dropPos = dropDestination.position;
    420     var relatedNode = dropDestination.element.bookmarkNode;
    421     var dropInfoResult = {
    422         selectTarget: null,
    423         selectedTreeId: -1,
    424         parentId: dropPos == DropPosition.ON ? relatedNode.id :
    425                                                relatedNode.parentId,
    426         index: -1,
    427         relatedIndex: -1
    428       };
    429 
    430     // Try to find the index in the dataModel so we don't have to always keep
    431     // the index for the list items up to date.
    432     var overElement = getBookmarkElement(eventTarget);
    433     if (overElement instanceof ListItem) {
    434       dropInfoResult.relatedIndex =
    435           overElement.parentNode.dataModel.indexOf(relatedNode);
    436       dropInfoResult.selectTarget = list;
    437     } else if (overElement instanceof BookmarkList) {
    438       dropInfoResult.relatedIndex = overElement.dataModel.length - 1;
    439       dropInfoResult.selectTarget = list;
    440     } else {
    441       // Tree
    442       dropInfoResult.relatedIndex = relatedNode.index;
    443       dropInfoResult.selectTarget = tree;
    444       dropInfoResult.selectedTreeId =
    445           tree.selectedItem ? tree.selectedItem.bookmarkId : null;
    446     }
    447 
    448     if (dropPos == DropPosition.ABOVE)
    449       dropInfoResult.index = dropInfoResult.relatedIndex;
    450     else if (dropPos == DropPosition.BELOW)
    451       dropInfoResult.index = dropInfoResult.relatedIndex + 1;
    452 
    453     return dropInfoResult;
    454   }
    455 
    456   function handleDragLeave(e) {
    457     dropIndicator.finish();
    458   }
    459 
    460   function handleDrop(e) {
    461     var dropInfo = calculateDropInfo(e.target, dropDestination);
    462     if (dropInfo) {
    463       selectItemsAfterUserAction(dropInfo.selectTarget,
    464                                  dropInfo.selectedTreeId);
    465       if (dropInfo.index != -1)
    466         chrome.bookmarkManagerPrivate.drop(dropInfo.parentId, dropInfo.index);
    467       else
    468         chrome.bookmarkManagerPrivate.drop(dropInfo.parentId);
    469 
    470       e.preventDefault();
    471     }
    472     dropDestination = null;
    473     dropIndicator.finish();
    474   }
    475 
    476   function clearDragData() {
    477     dragInfo.clearDragData();
    478     dropDestination = null;
    479   }
    480 
    481   function init(selectItemsAfterUserActionFunction) {
    482     function deferredClearData() {
    483       setTimeout(clearDragData);
    484     }
    485 
    486     selectItemsAfterUserAction = selectItemsAfterUserActionFunction;
    487 
    488     document.addEventListener('dragstart', handleDragStart);
    489     document.addEventListener('dragenter', handleDragEnter);
    490     document.addEventListener('dragover', handleDragOver);
    491     document.addEventListener('dragleave', handleDragLeave);
    492     document.addEventListener('drop', handleDrop);
    493     document.addEventListener('dragend', deferredClearData);
    494     document.addEventListener('mouseup', deferredClearData);
    495 
    496     chrome.bookmarkManagerPrivate.onDragEnter.addListener(
    497         dragInfo.handleChromeDragEnter);
    498     chrome.bookmarkManagerPrivate.onDragLeave.addListener(deferredClearData);
    499     chrome.bookmarkManagerPrivate.onDrop.addListener(deferredClearData);
    500   }
    501   return {init: init};
    502 });
    503