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 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