blob: b4859a6310aaaa38723c993580a05ebc6a181fca [file] [log] [blame]
// Copyright 2013 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.
cr.define('dnd', function() {
'use strict';
/** @const */ var BookmarkList = bmm.BookmarkList;
/** @const */ var ListItem = cr.ui.ListItem;
/** @const */ var TreeItem = cr.ui.TreeItem;
/**
* 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
};
/**
* @type {Object} Drop information calculated in |handleDragOver|.
*/
var dropDestination = null;
/**
* @type {number} Timer id used to help minimize flicker.
*/
var removeDropIndicatorTimer;
/**
* The element that had a style applied it to indicate the drop location.
* This is used to easily remove the style when necessary.
* @type {Element}
*/
var lastIndicatorElement;
/**
* The style that was applied to indicate the drop location.
* @type {string}
*/
var lastIndicatorClassName;
var dropIndicator = {
/**
* Applies the drop indicator style on the target element and stores that
* information to easily remove the style in the future.
*/
addDropIndicatorStyle: function(indicatorElement, position) {
var indicatorStyleName = position == DropPosition.ABOVE ? 'drag-above' :
position == DropPosition.BELOW ? 'drag-below' :
'drag-on';
lastIndicatorElement = indicatorElement;
lastIndicatorClassName = indicatorStyleName;
indicatorElement.classList.add(indicatorStyleName);
},
/**
* Clears the drop indicator style from the last element was the drop target
* so the drop indicator is no longer for that element.
*/
removeDropIndicatorStyle: function() {
if (!lastIndicatorElement || !lastIndicatorClassName)
return;
lastIndicatorElement.classList.remove(lastIndicatorClassName);
lastIndicatorElement = null;
lastIndicatorClassName = null;
},
/**
* Displays the drop indicator on the current drop target to give the
* user feedback on where the drop will occur.
*/
update: function(dropDest) {
window.clearTimeout(removeDropIndicatorTimer);
var indicatorElement = dropDest.element;
var position = dropDest.position;
if (dropDest.element instanceof BookmarkList) {
// For an empty bookmark list use 'drop-above' style.
position = DropPosition.ABOVE;
} else if (dropDest.element instanceof TreeItem) {
indicatorElement = indicatorElement.querySelector('.tree-row');
}
dropIndicator.removeDropIndicatorStyle();
dropIndicator.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(removeDropIndicatorTimer);
removeDropIndicatorTimer = window.setTimeout(function() {
dropIndicator.removeDropIndicatorStyle();
}, 100);
}
};
/**
* Delay for expanding folder when pointer hovers on folder in tree view in
* milliseconds.
* @type {number}
* @const
*/
// TODO(yosin): EXPAND_FOLDER_DELAY should follow system settings. 400ms is
// taken from Windows default settings.
var EXPAND_FOLDER_DELAY = 400;
/**
* The timestamp when the mouse was over a folder during a drag operation.
* Used to open the hovered folder after a certain time.
* @type {number}
*/
var lastHoverOnFolderTimeStamp = 0;
/**
* Expand a folder if the user has hovered for longer than the specified
* time during a drag action.
*/
function updateAutoExpander(eventTimeStamp, overElement) {
// Expands a folder in tree view when pointer hovers on it longer than
// EXPAND_FOLDER_DELAY.
var hoverOnFolderTimeStamp = lastHoverOnFolderTimeStamp;
lastHoverOnFolderTimeStamp = 0;
if (hoverOnFolderTimeStamp) {
if (eventTimeStamp - hoverOnFolderTimeStamp >= EXPAND_FOLDER_DELAY)
overElement.expanded = true;
else
lastHoverOnFolderTimeStamp = hoverOnFolderTimeStamp;
} else if (overElement instanceof TreeItem &&
bmm.isFolder(overElement.bookmarkNode) &&
overElement.hasChildren &&
!overElement.expanded) {
lastHoverOnFolderTimeStamp = eventTimeStamp;
}
}
/**
* Stores the information abou the bookmark and folders being dragged.
* @type {Object}
*/
var dragData = null;
var dragInfo = {
handleChromeDragEnter: function(newDragData) {
dragData = newDragData;
},
clearDragData: function() {
dragData = null;
},
isDragValid: function() {
return !!dragData;
},
isSameProfile: function() {
return dragData && dragData.sameProfile;
},
isDraggingFolders: function() {
return dragData && dragData.elements.some(function(node) {
return !node.url;
});
},
isDraggingBookmark: function(bookmarkId) {
return dragData && dragData.elements.some(function(node) {
return node.id == bookmarkId;
});
},
isDraggingChildBookmark: function(folderId) {
return dragData && dragData.elements.some(function(node) {
return node.parentId == folderId;
});
},
isDraggingFolderToDescendant: function(bookmarkNode) {
return dragData && dragData.elements.some(function(node) {
var dragFolder = bmm.treeLookup[node.id];
var dragFolderNode = dragFolder && dragFolder.bookmarkNode;
return dragFolderNode && bmm.contains(dragFolderNode, bookmarkNode);
});
}
};
/**
* External function to select folders or bookmarks after a drop action.
* @type {function}
*/
var selectItemsAfterUserAction = null;
function getBookmarkElement(el) {
while (el && !el.bookmarkNode) {
el = el.parentNode;
}
return el;
}
// If we are over the list and the list is showing search result, we cannot
// drop.
function isOverSearch(overElement) {
return list.isSearch() && list.contains(overElement);
}
/**
* Determines the valid drop positions for the given target element.
* @param {!HTMLElement} overElement The element that we are currently
* dragging over.
* @return {DropPosition} An bit field enumeration of valid drop locations.
*/
function calculateValidDropTargets(overElement) {
if (!dragInfo.isDragValid() || isOverSearch(overElement))
return DropPosition.NONE;
if (dragInfo.isSameProfile() &&
(dragInfo.isDraggingBookmark(overElement.bookmarkNode.id) ||
dragInfo.isDraggingFolderToDescendant(overElement.bookmarkNode))) {
return DropPosition.NONE;
}
var canDropInfo = calculateDropAboveBelow(overElement);
if (canDropOn(overElement))
canDropInfo |= DropPosition.ON;
return canDropInfo;
}
function calculateDropAboveBelow(overElement) {
if (overElement instanceof BookmarkList)
return DropPosition.NONE;
// We cannot drop between Bookmarks bar and Other bookmarks.
if (overElement.bookmarkNode.parentId == bmm.ROOT_ID)
return DropPosition.NONE;
var isOverTreeItem = overElement instanceof TreeItem;
var isOverExpandedTree = isOverTreeItem && overElement.expanded;
var isDraggingFolders = dragInfo.isDraggingFolders();
// We can only drop between items in the tree if we have any folders.
if (isOverTreeItem && !isDraggingFolders)
return DropPosition.NONE;
// When dragging from a different profile we do not need to consider
// conflicts between the dragged items and the drop target.
if (!dragInfo.isSameProfile()) {
// Don't allow dropping below an expanded tree item since it is confusing
// to the user anyway.
return isOverExpandedTree ? DropPosition.ABOVE :
(DropPosition.ABOVE | DropPosition.BELOW);
}
var resultPositions = DropPosition.NONE;
// Cannot drop above if the item above is already in the drag source.
var previousElem = overElement.previousElementSibling;
if (!previousElem || !dragInfo.isDraggingBookmark(previousElem.bookmarkId))
resultPositions |= DropPosition.ABOVE;
// Don't allow dropping below an expanded tree item since it is confusing
// to the user anyway.
if (isOverExpandedTree)
return resultPositions;
// Cannot drop below if the item below is already in the drag source.
var nextElement = overElement.nextElementSibling;
if (!nextElement || !dragInfo.isDraggingBookmark(nextElement.bookmarkId))
resultPositions |= DropPosition.BELOW;
return resultPositions;
}
/**
* Determine whether we can drop the dragged items on the drop target.
* @param {!HTMLElement} overElement The element that we are currently
* dragging over.
* @return {boolean} Whether we can drop the dragged items on the drop
* target.
*/
function canDropOn(overElement) {
// We can only drop on a folder.
if (!bmm.isFolder(overElement.bookmarkNode))
return false;
if (!dragInfo.isSameProfile())
return true;
if (overElement instanceof BookmarkList) {
// We are trying to drop an item past the last item. This is
// only allowed if dragged item is different from the last item
// in the list.
var listItems = list.items;
var len = listItems.length;
if (!len || !dragInfo.isDraggingBookmark(listItems[len - 1].bookmarkId))
return true;
}
return !dragInfo.isDraggingChildBookmark(overElement.bookmarkNode.id);
}
/**
* Callback for the dragstart event.
* @param {Event} e The dragstart event.
*/
function handleDragStart(e) {
// Determine the selected bookmarks.
var target = e.target;
var draggedNodes = [];
if (target instanceof ListItem) {
// Use selected items.
draggedNodes = target.parentNode.selectedItems;
} else if (target instanceof TreeItem) {
draggedNodes.push(target.bookmarkNode);
}
// We manage starting the drag by using the extension API.
e.preventDefault();
if (draggedNodes.length) {
// If we are dragging a single link, we can do the *Link* effect.
// Otherwise, we only allow copy and move.
e.dataTransfer.effectAllowed = draggedNodes.length == 1 &&
!bmm.isFolder(draggedNodes[0]) ? 'copyMoveLink' : 'copyMove';
chrome.bookmarkManagerPrivate.startDrag(draggedNodes.map(function(node) {
return node.id;
}));
}
}
function handleDragEnter(e) {
e.preventDefault();
}
/**
* Calback for the dragover event.
* @param {Event} e The dragover event.
*/
function handleDragOver(e) {
// Allow DND on text inputs.
if (e.target.tagName != 'INPUT') {
// The default operation is to allow dropping links etc to do navigation.
// We never want to do that for the bookmark manager.
e.preventDefault();
// Set to none. This will get set to something if we can do the drop.
e.dataTransfer.dropEffect = 'none';
}
if (!dragInfo.isDragValid())
return;
var overElement = getBookmarkElement(e.target) ||
(e.target == list ? list : null);
if (!overElement)
return;
updateAutoExpander(e.timeStamp, overElement);
var canDropInfo = calculateValidDropTargets(overElement);
if (canDropInfo == DropPosition.NONE)
return;
// Now we know that we can drop. Determine if we will drop above, on or
// below based on mouse position etc.
dropDestination = calcDropPosition(e.clientY, overElement, canDropInfo);
if (!dropDestination) {
e.dataTransfer.dropEffect = 'none';
return;
}
e.dataTransfer.dropEffect = dragInfo.isSameProfile() ? 'move' : 'copy';
dropIndicator.update(dropDestination);
}
/**
* This function determines where the drop will occur relative to the element.
* @return {?Object} If no valid drop position is found, null, otherwise
* an object containing the following parameters:
* element - The target element that will receive the drop.
* position - A |DropPosition| relative to the |element|.
*/
function calcDropPosition(elementClientY, overElement, canDropInfo) {
if (overElement instanceof BookmarkList) {
// Dropping on the BookmarkList either means dropping below the last
// bookmark element or on the list itself if it is empty.
var length = overElement.items.length;
if (length)
return {
element: overElement.getListItemByIndex(length - 1),
position: DropPosition.BELOW
};
return {element: overElement, position: DropPosition.ON};
}
var above = canDropInfo & DropPosition.ABOVE;
var below = canDropInfo & DropPosition.BELOW;
var on = canDropInfo & DropPosition.ON;
var rect = overElement.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;
}
function calculateDropInfo(eventTarget, dropDestination) {
if (!dropDestination || !dragInfo.isDragValid())
return null;
var dropPos = dropDestination.position;
var relatedNode = dropDestination.element.bookmarkNode;
var dropInfoResult = {
selectTarget: null,
selectedTreeId: -1,
parentId: dropPos == DropPosition.ON ? relatedNode.id :
relatedNode.parentId,
index: -1,
relatedIndex: -1
};
// Try to find the index in the dataModel so we don't have to always keep
// the index for the list items up to date.
var overElement = getBookmarkElement(eventTarget);
if (overElement instanceof ListItem) {
dropInfoResult.relatedIndex =
overElement.parentNode.dataModel.indexOf(relatedNode);
dropInfoResult.selectTarget = list;
} else if (overElement instanceof BookmarkList) {
dropInfoResult.relatedIndex = overElement.dataModel.length - 1;
dropInfoResult.selectTarget = list;
} else {
// Tree
dropInfoResult.relatedIndex = relatedNode.index;
dropInfoResult.selectTarget = tree;
dropInfoResult.selectedTreeId =
tree.selectedItem ? tree.selectedItem.bookmarkId : null;
}
if (dropPos == DropPosition.ABOVE)
dropInfoResult.index = dropInfoResult.relatedIndex;
else if (dropPos == DropPosition.BELOW)
dropInfoResult.index = dropInfoResult.relatedIndex + 1;
return dropInfoResult;
}
function handleDragLeave(e) {
dropIndicator.finish();
}
function handleDrop(e) {
var dropInfo = calculateDropInfo(e.target, dropDestination);
if (dropInfo) {
selectItemsAfterUserAction(dropInfo.selectTarget,
dropInfo.selectedTreeId);
if (dropInfo.index != -1)
chrome.bookmarkManagerPrivate.drop(dropInfo.parentId, dropInfo.index);
else
chrome.bookmarkManagerPrivate.drop(dropInfo.parentId);
e.preventDefault();
}
dropDestination = null;
dropIndicator.finish();
}
function clearDragData() {
dragInfo.clearDragData();
dropDestination = null;
}
function init(selectItemsAfterUserActionFunction) {
function deferredClearData() {
setTimeout(clearDragData);
}
selectItemsAfterUserAction = selectItemsAfterUserActionFunction;
document.addEventListener('dragstart', handleDragStart);
document.addEventListener('dragenter', handleDragEnter);
document.addEventListener('dragover', handleDragOver);
document.addEventListener('dragleave', handleDragLeave);
document.addEventListener('drop', handleDrop);
document.addEventListener('dragend', deferredClearData);
document.addEventListener('mouseup', deferredClearData);
chrome.bookmarkManagerPrivate.onDragEnter.addListener(
dragInfo.handleChromeDragEnter);
chrome.bookmarkManagerPrivate.onDragLeave.addListener(deferredClearData);
chrome.bookmarkManagerPrivate.onDrop.addListener(deferredClearData);
}
return {init: init};
});