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