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