Chromium Code Reviews| Index: chrome/browser/resources/md_bookmarks/dnd_manager.js |
| diff --git a/chrome/browser/resources/md_bookmarks/dnd_manager.js b/chrome/browser/resources/md_bookmarks/dnd_manager.js |
| new file mode 100644 |
| index 0000000000000000000000000000000000000000..cda4c8cad756423f5b60c3913d2df179c0b979d1 |
| --- /dev/null |
| +++ b/chrome/browser/resources/md_bookmarks/dnd_manager.js |
| @@ -0,0 +1,522 @@ |
| +// Copyright 2017 The Chromium Authors. All rights reserved. |
| +// Use of this source code is governed by a BSD-style license that can be |
| +// found in the LICENSE file. |
| + |
| +/** |
| + * Enumeration of valid drop locations relative to an element. These are |
| + * bit masks to allow combining multiple locations in a single value. |
| + * @enum {number} |
| + * @const |
| + */ |
| +var DropPosition = { |
| + NONE: 0, |
| + ABOVE: 1, |
| + ON: 2, |
| + BELOW: 4, |
| +}; |
| + |
| +/** @typedef {{element: BookmarkElement, position: DropPosition}} */ |
| +var DropDestination; |
| + |
| +cr.define('bookmarks', function() { |
| + /** |
| + * @param {BookmarkElement} element |
| + * @return {boolean} |
| + */ |
| + function isBookmarkItem(element) { |
| + return element.tagName == 'BOOKMARKS-ITEM'; |
| + } |
| + |
| + /** |
| + * @param {BookmarkElement} element |
| + * @return {boolean} |
| + */ |
| + function isBookmarkFolderNode(element) { |
| + return element.tagName == 'BOOKMARKS-FOLDER-NODE'; |
| + } |
| + |
| + /** |
| + * @param {Array<!Element>|undefined} path |
| + * @return {BookmarkElement} |
| + */ |
| + function getBookmarkElement(path) { |
| + if (!path) |
| + return null; |
| + |
| + for (var i = 0; i < path.length; i++) { |
| + if (isBookmarkItem(path[i]) || isBookmarkFolderNode(path[i])) |
| + return path[i]; |
| + } |
| + return null; |
| + } |
| + |
| + /** |
| + * @param {BookmarkElement} bookmarkElement |
| + * @return {BookmarkNode} |
| + */ |
| + function getBookmarkNode(bookmarkElement) { |
| + return bookmarks.Store.getInstance().data.nodes[bookmarkElement.itemId]; |
| + } |
| + |
| + /** |
| + * Contains and provides utility methods for drag data sent by the |
| + * bookmarkManagerPrivate API. |
| + * @constructor |
| + */ |
| + function DragInfo() { |
| + /** @type {DragData} */ |
| + this.dragData = null; |
| + } |
| + |
| + DragInfo.prototype = { |
| + /** @param {DragData} newDragData */ |
| + handleChromeDragEnter: function(newDragData) { |
| + this.dragData = newDragData; |
| + }, |
| + |
| + clearDragData: function() { |
| + this.dragData = null; |
| + }, |
| + |
| + /** @return {boolean} */ |
| + isDragValid: function() { |
| + return !!this.dragData; |
| + }, |
| + |
| + /** @return {boolean} */ |
| + isSameProfile: function() { |
| + return !!this.dragData && this.dragData.sameProfile; |
| + }, |
| + |
| + /** @return {boolean} */ |
| + isDraggingFolders: function() { |
| + return !!this.dragData && this.dragData.elements.some(function(node) { |
| + return !node.url; |
| + }); |
| + }, |
| + |
| + /** @return {boolean} */ |
| + isDraggingBookmark: function(bookmarkId) { |
| + return !!this.dragData && this.isSameProfile() && |
| + this.dragData.elements.some(function(node) { |
| + return node.id == bookmarkId; |
| + }); |
| + }, |
| + |
| + /** @return {boolean} */ |
| + isDraggingChildBookmark: function(folderId) { |
| + return !!this.dragData && this.isSameProfile() && |
| + this.dragData.elements.some(function(node) { |
| + return node.parentId == folderId; |
| + }); |
| + }, |
| + |
| + /** @return {boolean} */ |
| + isDraggingFolderToDescendant: function(itemId, nodes) { |
| + if (!this.isSameProfile()) |
| + return false; |
| + |
| + var parentId = nodes[itemId].parentId; |
| + var parents = {}; |
| + while (parentId) { |
| + parents[parentId] = true; |
| + parentId = nodes[parentId].parentId; |
| + } |
| + |
| + return !!this.dragData && this.dragData.elements.some(function(node) { |
| + return parents[node.id]; |
| + }); |
| + }, |
| + }; |
| + |
| + /** |
| + * Encapsulates the behavior of the drag and drop indicator which puts a line |
| + * between items or highlights folders which are valid drop targets. |
| + * @constructor |
| + */ |
| + function DropIndicator() { |
| + /** |
| + * @type {number|null} Timer id used to help minimize flicker. |
| + */ |
| + this.removeDropIndicatorTimer = null; |
| + |
| + /** |
| + * The element that had a style applied it to indicate the drop location. |
| + * This is used to easily remove the style when necessary. |
| + * @type {BookmarkElement|null} |
| + */ |
| + this.lastIndicatorElement = null; |
| + |
| + /** |
| + * The style that was applied to indicate the drop location. |
| + * @type {?string|null} |
| + */ |
| + this.lastIndicatorClassName = null; |
| + |
| + /** |
| + * Used to instantly remove the indicator style in tests. |
| + * @type {function((Function|null|string), number)} |
|
tsergeant
2017/03/27 06:49:24
Nit: @private
calamity
2017/03/28 04:15:03
Done. Also privated the rest of the things here.
|
| + */ |
| + this.setTimeout_ = window.setTimeout.bind(window); |
| + } |
| + |
| + DropIndicator.prototype = { |
| + /** |
| + * Applies the drop indicator style on the target element and stores that |
| + * information to easily remove the style in the future. |
| + * @param {HTMLElement} indicatorElement |
| + * @param {DropPosition} position |
| + */ |
| + addDropIndicatorStyle: function(indicatorElement, position) { |
| + var indicatorStyleName = position == DropPosition.ABOVE ? |
| + 'drag-above' : |
| + position == DropPosition.BELOW ? 'drag-below' : 'drag-on'; |
| + |
| + this.lastIndicatorElement = indicatorElement; |
| + this.lastIndicatorClassName = indicatorStyleName; |
| + |
| + indicatorElement.classList.add(indicatorStyleName); |
| + }, |
| + |
| + /** |
| + * Clears the drop indicator style from the last element was the drop target |
|
tsergeant
2017/03/27 06:49:24
nit: this sentence doesn't quite grammar
maybe ju
calamity
2017/03/28 04:15:03
Uhhhhh. Done.
|
| + * so the drop indicator is no longer for that element. |
| + */ |
| + removeDropIndicatorStyle: function() { |
| + if (!this.lastIndicatorElement || !this.lastIndicatorClassName) |
| + return; |
| + |
| + this.lastIndicatorElement.classList.remove(this.lastIndicatorClassName); |
| + this.lastIndicatorElement = null; |
| + this.lastIndicatorClassName = null; |
| + }, |
| + |
| + /** |
| + * Displays the drop indicator on the current drop target to give the |
| + * user feedback on where the drop will occur. |
| + * @param {DropDestination} dropDest |
| + */ |
| + update: function(dropDest) { |
| + window.clearTimeout(this.removeDropIndicatorTimer); |
| + |
| + var indicatorElement = dropDest.element.getDropTarget(); |
| + var position = dropDest.position; |
| + |
| + this.removeDropIndicatorStyle(); |
| + this.addDropIndicatorStyle(indicatorElement, position); |
| + }, |
| + |
| + /** |
| + * Stop displaying the drop indicator. |
| + */ |
| + finish: function() { |
| + // The use of a timeout is in order to reduce flickering as we move |
| + // between valid drop targets. |
| + window.clearTimeout(this.removeDropIndicatorTimer); |
| + this.removeDropIndicatorTimer = this.setTimeout_(function() { |
| + this.removeDropIndicatorStyle(); |
| + }.bind(this), 100); |
| + }, |
| + |
| + disableTimeoutForTesting: function() { |
| + this.setTimeout_ = function(fn, timeout) { |
| + fn(); |
| + }; |
| + }, |
| + }; |
| + |
| + /** |
| + * Manages drag and drop events for the bookmarks-app. |
| + * @constructor |
| + */ |
| + function DNDManager() { |
| + /** @private {bookmarks.DragInfo} */ |
| + this.dragInfo_ = null; |
| + |
| + /** @private {?DropDestination} */ |
| + this.dropDestination_ = null; |
| + |
| + /** @private {bookmarks.DropIndicator} */ |
| + this.dropIndicator_ = null; |
| + |
| + /** @private {Object<string, function(!Event)>} */ |
| + this.documentListeners_ = null; |
| + } |
| + |
| + DNDManager.prototype = { |
| + init: function() { |
| + this.dragInfo_ = new DragInfo(); |
| + this.dropIndicator_ = new DropIndicator(); |
| + |
| + this.documentListeners_ = { |
| + 'dragstart': this.onDragStart_.bind(this), |
| + 'dragenter': this.onDragEnter_.bind(this), |
| + 'dragover': this.onDragOver_.bind(this), |
| + 'dragleave': this.onDragLeave_.bind(this), |
| + 'drop': this.onDrop_.bind(this), |
| + 'dragend': this.clearDragData_.bind(this), |
| + 'mouseup': this.clearDragData_.bind(this), |
| + // TODO(calamity): Add touch support. |
| + }; |
| + for (var event in this.documentListeners_) |
| + document.addEventListener(event, this.documentListeners_[event]); |
| + |
| + chrome.bookmarkManagerPrivate.onDragEnter.addListener( |
| + this.dragInfo_.handleChromeDragEnter.bind(this.dragInfo_)); |
| + chrome.bookmarkManagerPrivate.onDragLeave.addListener( |
| + this.clearDragData_.bind(this)); |
| + chrome.bookmarkManagerPrivate.onDrop.addListener( |
| + this.clearDragData_.bind(this)); |
| + }, |
| + |
| + destroy: function() { |
| + for (var event in this.documentListeners_) |
| + document.removeEventListener(event, this.documentListeners_[event]); |
| + }, |
| + |
| + /** @private */ |
| + onDragLeave_: function() { |
| + this.dropIndicator_.finish(); |
| + }, |
| + |
| + /** |
| + * @private |
| + * @param {!Event} e |
| + */ |
| + onDrop_: function(e) { |
| + if (this.dropDestination_) { |
|
tsergeant
2017/03/27 06:49:23
Nit: {}
calamity
2017/03/28 04:15:03
Done.
|
| + e.preventDefault(); |
| + } |
| + this.dropDestination_ = null; |
| + this.dropIndicator_.finish(); |
| + }, |
| + |
| + /** @private */ |
| + clearDragData_: function() { |
| + this.dragInfo_.clearDragData(); |
| + this.dropDestination_ = null; |
| + this.dropIndicator_.finish(); |
| + }, |
| + |
| + /** |
| + * @private |
| + * @param {Event} e |
| + */ |
| + onDragStart_: function(e) { |
| + var dragElement = getBookmarkElement(e.path); |
| + if (!dragElement) |
| + return; |
| + |
| + // Determine the selected bookmarks. |
| + var state = bookmarks.Store.getInstance().data; |
| + var draggedNodes = Object.keys(state.selection.items); |
|
tsergeant
2017/03/27 06:49:24
Hrmm, at the moment we don't really guarantee from
calamity
2017/03/28 04:15:03
Done.
Remind me why we didn't use a Set again?
tsergeant
2017/03/28 23:52:22
Yeah, we could probably give it a go with a Set.
|
| + |
| + if (isBookmarkFolderNode(dragElement) || |
| + draggedNodes.indexOf(dragElement.itemId) == -1) { |
| + // TODO(calamity): clear current selection. |
| + draggedNodes = [dragElement.itemId]; |
| + } |
| + |
| + e.preventDefault(); |
| + |
| + // If we are dragging a single link, we can do the *Link* effect. |
| + // Otherwise, we only allow copy and move. |
| + if (e.dataTransfer) { |
| + e.dataTransfer.effectAllowed = |
| + draggedNodes.length == 1 && state.nodes[draggedNodes[0]].url ? |
| + 'copyLink' : |
| + 'copyMove'; |
| + } |
| + |
| + // TODO(calamity): account for touch. |
| + chrome.bookmarkManagerPrivate.startDrag(draggedNodes, false); |
| + }, |
| + |
| + /** |
| + * @private |
| + * @param {Event} e |
| + */ |
| + onDragEnter_: function(e) { |
| + e.preventDefault(); |
| + }, |
| + |
| + /** |
| + * @private |
| + * @param {Event} e |
| + */ |
| + onDragOver_: function(e) { |
| + this.dropDestination_ = null; |
| + |
| + // This is necessary to actually trigger the 'none' effect, even though |
| + // the event will have this set to 'none' already. |
|
tsergeant
2017/03/27 06:49:24
😕
calamity
2017/03/28 04:15:03
Mate, you have no idea.
|
| + if (e.dataTransfer) |
| + e.dataTransfer.dropEffect = 'none'; |
| + |
| + // Allow normal DND on text inputs. |
| + if (e.path[0].tagName == 'INPUT') |
| + return; |
| + |
| + // The default operation is to allow dropping links etc to do |
| + // navigation. We never want to do that for the bookmark manager. |
| + e.preventDefault(); |
| + |
| + if (!this.dragInfo_.isDragValid()) |
| + return; |
| + |
| + var overElement = getBookmarkElement(e.path); |
| + if (!overElement) |
| + return; |
| + |
| + // TODO(calamity): open folders on hover. |
| + |
| + // Now we know that we can drop. Determine if we will drop above, on or |
| + // below based on mouse position etc. |
| + this.dropDestination_ = |
| + this.calculateDropDestination_(e.clientY, overElement); |
| + if (!this.dropDestination_) |
| + return; |
| + |
| + if (e.dataTransfer) { |
| + e.dataTransfer.dropEffect = |
| + this.dragInfo_.isSameProfile() ? 'move' : 'copy'; |
| + } |
| + |
| + this.dropIndicator_.update(this.dropDestination_); |
| + }, |
| + |
| + /** |
| + * This function determines where the drop will occur. |
| + * @private |
| + * @param {number} elementClientY |
| + * @param {!BookmarkElement} overElement |
| + * @return {?DropDestination} If no valid drop position is found, null, |
| + * otherwise: |
| + * element - The target element that will receive the drop. |
| + * position - A |DropPosition| relative to the |element|. |
| + */ |
| + calculateDropDestination_: function(elementClientY, overElement) { |
| + var validDropPositions = this.calculateValidDropPositions_(overElement); |
| + if (validDropPositions == DropPosition.NONE) |
| + return null; |
| + |
| + var above = validDropPositions & DropPosition.ABOVE; |
| + var below = validDropPositions & DropPosition.BELOW; |
| + var on = validDropPositions & DropPosition.ON; |
| + var rect = overElement.getDropTarget().getBoundingClientRect(); |
| + var yRatio = (elementClientY - rect.top) / rect.height; |
| + |
| + if (above && (yRatio <= .25 || yRatio <= .5 && (!below || !on))) |
| + return {element: overElement, position: DropPosition.ABOVE}; |
| + |
| + if (below && (yRatio > .75 || yRatio > .5 && (!above || !on))) |
| + return {element: overElement, position: DropPosition.BELOW}; |
| + |
| + if (on) |
| + return {element: overElement, position: DropPosition.ON}; |
| + |
| + return null; |
| + }, |
| + |
| + /** |
| + * Determines the valid drop positions for the given target element. |
| + * @private |
| + * @param {!BookmarkElement} overElement The element that we are currently |
| + * dragging over. |
| + * @return {DropPosition} An bit field enumeration of valid drop locations. |
| + */ |
| + calculateValidDropPositions_: function(overElement) { |
| + var dragInfo = this.dragInfo_; |
| + var state = bookmarks.Store.getInstance().data; |
| + |
| + // Drags aren't allowed onto the search result list. |
| + if (isBookmarkItem(overElement) && |
| + bookmarks.util.isShowingSearch(state)) { |
| + return DropPosition.NONE; |
| + } |
| + |
| + // Drags of a bookmark onto itself or of a folder into its children aren't |
| + // allowed. |
| + if (dragInfo.isDraggingBookmark(overElement.itemId) || |
| + dragInfo.isDraggingFolderToDescendant( |
| + overElement.itemId, state.nodes)) { |
| + return DropPosition.NONE; |
| + } |
| + |
| + var validDropPositions = this.calculateDropAboveBelow_(overElement); |
| + if (this.canDropOn_(overElement)) |
| + validDropPositions |= DropPosition.ON; |
| + |
| + return validDropPositions; |
| + }, |
| + |
| + /** |
| + * @private |
| + * @param {BookmarkElement} overElement |
| + * @return {DropPosition} |
| + */ |
| + calculateDropAboveBelow_: function(overElement) { |
| + var dragInfo = this.dragInfo_; |
| + var state = bookmarks.Store.getInstance().data; |
| + |
| + // We cannot drop between Bookmarks bar and Other bookmarks. |
| + if (getBookmarkNode(overElement).parentId == bookmarks.util.ROOT_NODE_ID) |
| + return DropPosition.NONE; |
| + |
| + var isOverFolderNode = isBookmarkFolderNode(overElement); |
| + |
| + // We can only drop between items in the tree if we have any folders. |
| + if (isOverFolderNode && !dragInfo.isDraggingFolders()) |
| + return DropPosition.NONE; |
| + |
| + var validDropPositions = DropPosition.NONE; |
| + |
| + // Cannot drop above if the item above is already in the drag source. |
| + var previousElem = overElement.previousElementSibling; |
| + if (!previousElem || !dragInfo.isDraggingBookmark(previousElem.itemId)) { |
|
tsergeant
2017/03/27 06:49:24
Nit: {}
calamity
2017/03/28 04:15:03
Done.
|
| + validDropPositions |= DropPosition.ABOVE; |
| + } |
| + |
| + // Don't allow dropping below an expanded sidebar folder item since it is |
| + // confusing to the user anyway. |
| + if (isOverFolderNode && !state.closedFolders[overElement.itemId] && |
| + bookmarks.util.hasChildFolders(overElement.itemId, state.nodes)) { |
| + return validDropPositions; |
| + } |
| + |
| + // Cannot drop below if the item below is already in the drag source. |
|
tsergeant
2017/03/27 06:49:23
Does this comment belong on the if statement on li
calamity
2017/03/28 04:15:03
Ugh, I never know what to do with comments like th
|
| + var nextElement = overElement.nextElementSibling; |
| + |
| + // The template element sits past the end of the last bookmark element. |
| + if (!isBookmarkItem(nextElement) && !isBookmarkFolderNode(nextElement)) |
| + nextElement = null; |
| + |
| + if (!nextElement || !dragInfo.isDraggingBookmark(nextElement.itemId)) { |
|
tsergeant
2017/03/27 06:49:23
{} here too
calamity
2017/03/28 04:15:03
Done.
|
| + validDropPositions |= DropPosition.BELOW; |
| + } |
| + |
| + return validDropPositions; |
| + }, |
| + |
| + /** |
| + * Determine whether we can drop the dragged items on the drop target. |
| + * @private |
| + * @param {!BookmarkElement} overElement The element that we are currently |
| + * dragging over. |
| + * @return {boolean} Whether we can drop the dragged items on the drop |
| + * target. |
| + */ |
| + canDropOn_: function(overElement) { |
| + // We can only drop on a folder. |
| + if (getBookmarkNode(overElement).url) |
| + return false; |
| + |
| + return !this.dragInfo_.isDraggingChildBookmark(overElement.itemId) |
| + }, |
| + }; |
| + |
| + return { |
| + DNDManager: DNDManager, |
| + DragInfo: DragInfo, |
| + DropIndicator: DropIndicator, |
| + }; |
| +}); |