blob: c5e68703f2a77d3c4dceb86656bee67ad1ab5804 [file] [log] [blame]
// 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.
/** @typedef {?{elements: !Array<BookmarkNode>, sameProfile: boolean}} */
let NormalizedDragData;
cr.define('bookmarks', function() {
/** @const {number} */
const DRAG_THRESHOLD = 15;
/**
* @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 {BookmarkElement} element
* @return {boolean}
*/
function isBookmarkList(element) {
return element.tagName == 'BOOKMARKS-LIST';
}
/**
* @param {BookmarkElement} element
* @return {boolean}
*/
function isClosedBookmarkFolderNode(element) {
return isBookmarkFolderNode(element) &&
!(/** @type {BookmarksFolderNodeElement} */ (element).isOpen);
}
/**
* @param {Array<!Element>|undefined} path
* @return {BookmarkElement}
*/
function getBookmarkElement(path) {
if (!path) {
return null;
}
for (let i = 0; i < path.length; i++) {
if (isBookmarkItem(path[i]) || isBookmarkFolderNode(path[i]) ||
isBookmarkList(path[i])) {
return path[i];
}
}
return null;
}
/**
* @param {Array<!Element>|undefined} path
* @return {BookmarkElement}
*/
function getDragElement(path) {
const dragElement = getBookmarkElement(path);
for (let i = 0; i < path.length; i++) {
if (path[i].tagName == 'BUTTON') {
return null;
}
}
return dragElement && dragElement.getAttribute('draggable') ? dragElement :
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.
*/
class DragInfo {
constructor() {
/** @type {NormalizedDragData} */
this.dragData = null;
}
/** @param {DragData} newDragData */
setNativeDragData(newDragData) {
this.dragData = {
sameProfile: newDragData.sameProfile,
elements:
newDragData.elements.map((x) => bookmarks.util.normalizeNode(x))
};
}
clearDragData() {
this.dragData = null;
}
/** @return {boolean} */
isDragValid() {
return !!this.dragData;
}
/** @return {boolean} */
isSameProfile() {
return !!this.dragData && this.dragData.sameProfile;
}
/** @return {boolean} */
isDraggingFolders() {
return !!this.dragData && this.dragData.elements.some(function(node) {
return !node.url;
});
}
/** @return {boolean} */
isDraggingBookmark(bookmarkId) {
return !!this.dragData && this.isSameProfile() &&
this.dragData.elements.some(function(node) {
return node.id == bookmarkId;
});
}
/** @return {boolean} */
isDraggingChildBookmark(folderId) {
return !!this.dragData && this.isSameProfile() &&
this.dragData.elements.some(function(node) {
return node.parentId == folderId;
});
}
/** @return {boolean} */
isDraggingFolderToDescendant(itemId, nodes) {
if (!this.isSameProfile()) {
return false;
}
let parentId = nodes[itemId].parentId;
const parents = {};
while (parentId) {
parents[parentId] = true;
parentId = nodes[parentId].parentId;
}
return !!this.dragData && this.dragData.elements.some(function(node) {
return parents[node.id];
});
}
}
/**
* Manages auto expanding of sidebar folders on hover while dragging.
*/
class AutoExpander {
constructor() {
/** @const {number} */
this.EXPAND_FOLDER_DELAY = 400;
/** @private {?BookmarkElement} */
this.lastElement_ = null;
/** @type {!bookmarks.Debouncer} */
this.debouncer_ = new bookmarks.Debouncer(() => {
const store = bookmarks.Store.getInstance();
store.dispatch(
bookmarks.actions.changeFolderOpen(this.lastElement_.itemId, true));
this.reset();
});
}
/**
* @param {Event} e
* @param {?BookmarkElement} overElement
*/
update(e, overElement) {
const itemId = overElement ? overElement.itemId : null;
const store = bookmarks.Store.getInstance();
// If dragging over a new closed folder node with children reset the
// expander. Falls through to reset the expander delay.
if (overElement && overElement != this.lastElement_ &&
isClosedBookmarkFolderNode(overElement) &&
bookmarks.util.hasChildFolders(itemId, store.data.nodes)) {
this.reset();
this.lastElement_ = overElement;
}
// If dragging over the same node, reset the expander delay.
if (overElement && overElement == this.lastElement_) {
this.debouncer_.restartTimeout(this.EXPAND_FOLDER_DELAY);
return;
}
// Otherwise, cancel the expander.
this.reset();
}
reset() {
this.debouncer_.reset();
this.lastElement_ = null;
}
}
/**
* Encapsulates the behavior of the drag and drop indicator which puts a line
* between items or highlights folders which are valid drop targets.
*/
class DropIndicator {
constructor() {
/**
* @private {number|null} Timer id used to help minimize flicker.
*/
this.removeDropIndicatorTimeoutId_ = 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 {!Object}
*/
this.timerProxy = window;
}
/**
* 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(indicatorElement, position) {
const 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() {
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(dropDest) {
this.timerProxy.clearTimeout(this.removeDropIndicatorTimeoutId_);
this.removeDropIndicatorTimeoutId_ = null;
const indicatorElement = dropDest.element.getDropTarget();
const position = dropDest.position;
this.removeDropIndicatorStyle();
this.addDropIndicatorStyle(indicatorElement, position);
}
/**
* Stop displaying the drop indicator.
*/
finish() {
if (this.removeDropIndicatorTimeoutId_) {
return;
}
// The use of a timeout is in order to reduce flickering as we move
// between valid drop targets.
this.removeDropIndicatorTimeoutId_ = this.timerProxy.setTimeout(() => {
this.removeDropIndicatorStyle();
}, 100);
}
}
/**
* Manages drag and drop events for the bookmarks-app.
*/
class DNDManager {
constructor() {
/** @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;
/** @private {?bookmarks.AutoExpander} */
this.autoExpander_ = null;
/**
* Used to instantly clearDragData in tests.
* @private {!Object}
*/
this.timerProxy_ = window;
/** @private {boolean} */
this.lastPointerWasTouch_ = false;
}
init() {
this.dragInfo_ = new DragInfo();
this.dropIndicator_ = new DropIndicator();
this.autoExpander_ = new AutoExpander();
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),
'mousedown': this.onMouseDown_.bind(this),
'touchstart': this.onTouchStart_.bind(this),
};
for (const event in this.documentListeners_) {
document.addEventListener(event, this.documentListeners_[event]);
}
chrome.bookmarkManagerPrivate.onDragEnter.addListener(
this.handleChromeDragEnter_.bind(this));
chrome.bookmarkManagerPrivate.onDragLeave.addListener(
this.clearDragData_.bind(this));
}
destroy() {
for (const event in this.documentListeners_) {
document.removeEventListener(event, this.documentListeners_[event]);
}
}
////////////////////////////////////////////////////////////////////////////
// DragEvent handlers:
/**
* @private
* @param {Event} e
*/
onDragStart_(e) {
const dragElement = getDragElement(e.path);
if (!dragElement) {
return;
}
e.preventDefault();
const dragData = this.calculateDragData_(dragElement);
if (!dragData) {
this.clearDragData_();
return;
}
const state = bookmarks.Store.getInstance().data;
let draggedNodes = [];
if (isBookmarkItem(dragElement)) {
const displayingItems = assert(bookmarks.util.getDisplayedList(state));
// TODO(crbug.com/980427): Make this search more time efficient to avoid
// delay on large amount of bookmark dragging.
for (const itemId of displayingItems) {
for (const element of dragData.elements) {
if (element.id == itemId) {
draggedNodes.push(element.id);
break;
}
}
}
} else {
draggedNodes = dragData.elements.map((item) => item.id);
}
assert(draggedNodes.length == dragData.elements.length);
const dragNodeIndex = draggedNodes.indexOf(dragElement.itemId);
assert(dragNodeIndex != -1);
chrome.bookmarkManagerPrivate.startDrag(
draggedNodes, dragNodeIndex, this.lastPointerWasTouch_, e.clientX,
e.clientY);
}
/** @private */
onDragLeave_() {
this.dropIndicator_.finish();
}
/**
* @private
* @param {!Event} e
*/
onDrop_(e) {
// Allow normal DND on text inputs.
if (isTextInputElement(e.path[0])) {
return;
}
e.preventDefault();
if (this.dropDestination_) {
const dropInfo = this.calculateDropInfo_(this.dropDestination_);
const index = dropInfo.index != -1 ? dropInfo.index : undefined;
const shouldHighlight = this.shouldHighlight_(this.dropDestination_);
if (shouldHighlight) {
bookmarks.ApiListener.trackUpdatedItems();
}
chrome.bookmarkManagerPrivate.drop(
dropInfo.parentId, index,
shouldHighlight ? bookmarks.ApiListener.highlightUpdatedItems :
undefined);
}
this.clearDragData_();
}
/**
* @private
* @param {Event} e
*/
onDragEnter_(e) {
e.preventDefault();
}
/**
* @private
* @param {Event} e
*/
onDragOver_(e) {
this.dropDestination_ = null;
// Allow normal DND on text inputs.
if (isTextInputElement(e.path[0])) {
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;
}
const state = bookmarks.Store.getInstance().data;
const items = this.dragInfo_.dragData.elements;
const overElement = getBookmarkElement(e.path);
this.autoExpander_.update(e, overElement);
if (!overElement) {
this.dropIndicator_.finish();
return;
}
// 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_) {
this.dropIndicator_.finish();
return;
}
this.dropIndicator_.update(this.dropDestination_);
}
/** @private */
onMouseDown_() {
this.lastPointerWasTouch_ = false;
}
/** @private */
onTouchStart_() {
this.lastPointerWasTouch_ = true;
}
/**
* @private
* @param {DragData} dragData
*/
handleChromeDragEnter_(dragData) {
this.dragInfo_.setNativeDragData(dragData);
}
////////////////////////////////////////////////////////////////////////////
// Helper methods:
/** @private */
clearDragData_() {
this.autoExpander_.reset();
// Defer the clearing of the data so that the bookmark manager API's drop
// event doesn't clear the drop data before the web drop event has a
// chance to execute (on Mac).
this.timerProxy_.setTimeout(() => {
this.dragInfo_.clearDragData();
this.dropDestination_ = null;
this.dropIndicator_.finish();
}, 0);
}
/**
* @param {DropDestination} dropDestination
* @return {{parentId: string, index: number}}
*/
calculateDropInfo_(dropDestination) {
if (isBookmarkList(dropDestination.element)) {
return {
index: 0,
parentId: bookmarks.Store.getInstance().data.selectedFolder,
};
}
const node = getBookmarkNode(dropDestination.element);
const position = dropDestination.position;
let index = -1;
let parentId = node.id;
if (position != DropPosition.ON) {
const state = bookmarks.Store.getInstance().data;
// Drops between items in the normal list and the sidebar use the drop
// destination node's parent.
parentId = assert(node.parentId);
index = state.nodes[parentId].children.indexOf(node.id);
if (position == DropPosition.BELOW) {
index++;
}
}
return {
index: index,
parentId: parentId,
};
}
/**
* Calculates which items should be dragged based on the initial drag item
* and the current selection. Dragged items will end up selected.
* @param {!BookmarkElement} dragElement
* @private
*/
calculateDragData_(dragElement) {
const dragId = dragElement.itemId;
const store = bookmarks.Store.getInstance();
const state = store.data;
// Determine the selected bookmarks.
let draggedNodes = Array.from(state.selection.items);
// Change selection to the dragged node if the node is not part of the
// existing selection.
if (isBookmarkFolderNode(dragElement) ||
draggedNodes.indexOf(dragId) == -1) {
store.dispatch(bookmarks.actions.deselectItems());
if (!isBookmarkFolderNode(dragElement)) {
store.dispatch(bookmarks.actions.selectItem(dragId, state, {
clear: false,
range: false,
toggle: false,
}));
}
draggedNodes = [dragId];
}
// If any node can't be dragged, end the drag.
const anyUnmodifiable = draggedNodes.some(
(itemId) => !bookmarks.util.canEditNode(state, itemId));
if (anyUnmodifiable) {
return null;
}
return {
elements: draggedNodes.map((id) => state.nodes[id]),
sameProfile: true,
};
}
/**
* 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_(elementClientY, overElement) {
const validDropPositions = this.calculateValidDropPositions_(overElement);
if (validDropPositions == DropPosition.NONE) {
return null;
}
const above = validDropPositions & DropPosition.ABOVE;
const below = validDropPositions & DropPosition.BELOW;
const on = validDropPositions & DropPosition.ON;
const rect = overElement.getDropTarget().getBoundingClientRect();
const 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 {number} An bit field enumeration of valid drop locations.
*/
calculateValidDropPositions_(overElement) {
const dragInfo = this.dragInfo_;
const state = bookmarks.Store.getInstance().data;
let itemId = overElement.itemId;
// Drags aren't allowed onto the search result list.
if ((isBookmarkList(overElement) || isBookmarkItem(overElement)) &&
bookmarks.util.isShowingSearch(state)) {
return DropPosition.NONE;
}
if (isBookmarkList(overElement)) {
itemId = state.selectedFolder;
}
if (!bookmarks.util.canReorderChildren(state, itemId)) {
return DropPosition.NONE;
}
// Drags of a bookmark onto itself or of a folder into its children aren't
// allowed.
if (dragInfo.isDraggingBookmark(itemId) ||
dragInfo.isDraggingFolderToDescendant(itemId, state.nodes)) {
return DropPosition.NONE;
}
let validDropPositions = this.calculateDropAboveBelow_(overElement);
if (this.canDropOn_(overElement)) {
validDropPositions |= DropPosition.ON;
}
return validDropPositions;
}
/**
* @private
* @param {BookmarkElement} overElement
* @return {number}
*/
calculateDropAboveBelow_(overElement) {
const dragInfo = this.dragInfo_;
const state = bookmarks.Store.getInstance().data;
if (isBookmarkList(overElement)) {
return DropPosition.NONE;
}
// We cannot drop between Bookmarks bar and Other bookmarks.
if (getBookmarkNode(overElement).parentId == ROOT_NODE_ID) {
return DropPosition.NONE;
}
const isOverFolderNode = isBookmarkFolderNode(overElement);
// We can only drop between items in the tree if we have any folders.
if (isOverFolderNode && !dragInfo.isDraggingFolders()) {
return DropPosition.NONE;
}
let validDropPositions = DropPosition.NONE;
// Cannot drop above if the item above is already in the drag source.
const 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 && !isClosedBookmarkFolderNode(overElement) &&
bookmarks.util.hasChildFolders(overElement.itemId, state.nodes)) {
return validDropPositions;
}
const nextElement = overElement.nextElementSibling;
// 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_(overElement) {
// Allow dragging onto empty bookmark lists.
if (isBookmarkList(overElement)) {
const state = bookmarks.Store.getInstance().data;
return !!state.selectedFolder &&
state.nodes[state.selectedFolder].children.length == 0;
}
// We can only drop on a folder.
if (getBookmarkNode(overElement).url) {
return false;
}
return !this.dragInfo_.isDraggingChildBookmark(overElement.itemId);
}
/**
* @param {DropDestination} dropDestination
* @private
*/
shouldHighlight_(dropDestination) {
return isBookmarkItem(dropDestination.element) ||
isBookmarkList(dropDestination.element);
}
/** @param {!Object} timerProxy */
setTimerProxyForTesting(timerProxy) {
this.timerProxy_ = timerProxy;
this.dropIndicator_.timerProxy = timerProxy;
}
}
return {
AutoExpander: AutoExpander,
DNDManager: DNDManager,
DragInfo: DragInfo,
DropIndicator: DropIndicator,
};
});