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..5304a097925c4814a01d081614ccf30334d34101 |
--- /dev/null |
+++ b/chrome/browser/resources/md_bookmarks/dnd_manager.js |
@@ -0,0 +1,519 @@ |
+// 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() { |
+ /** |
+ * @private {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. |
+ * @private {BookmarkElement|null} |
+ */ |
+ this.lastIndicatorElement_ = null; |
+ |
+ /** |
+ * The style that was applied to indicate the drop location. |
+ * @private {?string|null} |
+ */ |
+ this.lastIndicatorClassName_ = null; |
+ |
+ /** |
+ * Used to instantly remove the indicator style in tests. |
+ * @private {function((Function|null|string), number)} |
+ */ |
+ 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 drop target. |
+ */ |
+ 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_) |
+ 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); |
+ |
+ 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. |
+ 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)) |
+ 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; |
+ } |
+ |
+ var nextElement = overElement.nextElementSibling; |
+ |
+ // The template element sits past the end of the last bookmark element. |
+ if (!isBookmarkItem(nextElement) && !isBookmarkFolderNode(nextElement)) |
+ nextElement = null; |
+ |
+ // Cannot drop below if the item below is already in the drag source. |
+ if (!nextElement || !dragInfo.isDraggingBookmark(nextElement.itemId)) |
+ 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, |
+ }; |
+}); |