blob: 30fbdbbae331e9597e073968a782e677900f0844 [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.
import {assert} from 'chrome://resources/js/assert.m.js';
import {isTextInputElement} from 'chrome://resources/js/util.m.js';
import {changeFolderOpen, deselectItems, selectItem} from './actions.js';
import {highlightUpdatedItems, trackUpdatedItems} from './api_listener.js';
import {DropPosition, ROOT_NODE_ID} from './constants.js';
import {Debouncer} from './debouncer.js';
import {Store} from './store.js';
import {BookmarkElement, BookmarkNode, DragData, DropDestination} from './types.js';
import {canEditNode, canReorderChildren, getDisplayedList, hasChildFolders, isShowingSearch, normalizeNode} from './util.js';
/** @typedef {?{elements: !Array<BookmarkNode>, sameProfile: boolean}} */
let NormalizedDragData;
/** @const {number} */
const DRAG_THRESHOLD = 15;
/**
* @param {Element} element
* @return {boolean}
*/
function isBookmarkItem(element) {
return element.tagName === 'BOOKMARKS-ITEM';
}
/**
* @param {Element} element
* @return {boolean}
*/
function isBookmarkFolderNode(element) {
return element.tagName === 'BOOKMARKS-FOLDER-NODE';
}
/**
* @param {Element} element
* @return {boolean}
*/
function isBookmarkList(element) {
return element.tagName === 'BOOKMARKS-LIST';
}
/**
* @param {?Element} element
* @return {?Element}
*/
function getPreviousElementSibling(element) {
if (isBookmarkItem(element)) {
// Need to get previous element cousin.
const parentSibling = element.parentElement.previousElementSibling;
return parentSibling ? parentSibling.querySelector('bookmarks-item') : null;
}
return element.previousElementSibling;
}
/**
* @param {?Element} element
* @return {?Element}
*/
function getNextElementSibling(element) {
if (isBookmarkItem(element)) {
// Need to get next element cousin.
const parentSibling = element.parentElement.nextElementSibling;
return parentSibling ? parentSibling.querySelector('bookmarks-item') : null;
}
return element.nextElementSibling;
}
/**
* @param {Element} element
* @return {boolean}
*/
function isClosedBookmarkFolderNode(element) {
return isBookmarkFolderNode(/** @type {BookmarkElement} */ (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 /** @type {BookmarkElement} */ (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 Store.getInstance().data.nodes[bookmarkElement.itemId];
}
/**
* Contains and provides utility methods for drag data sent by the
* bookmarkManagerPrivate API.
*/
export class DragInfo {
constructor() {
/** @type {NormalizedDragData} */
this.dragData = null;
}
/** @param {DragData} newDragData */
setNativeDragData(newDragData) {
this.dragData = {
sameProfile: newDragData.sameProfile,
elements: newDragData.elements.map((x) => 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 {!Debouncer} */
this.debouncer_ = new Debouncer(() => {
const store = Store.getInstance();
store.dispatch(changeFolderOpen(this.lastElement_.itemId, true));
this.reset();
});
}
/**
* @param {Event} e
* @param {?BookmarkElement} overElement
*/
update(e, overElement) {
const itemId = overElement ? overElement.itemId : null;
const store = 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) &&
hasChildFolders(/** @type {string} */ (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_ =
/** @type {BookmarkElement} */ (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.
*/
export class DNDManager {
constructor() {
/** @private {DragInfo} */
this.dragInfo_ = null;
/** @private {?DropDestination} */
this.dropDestination_ = null;
/** @private {DropIndicator} */
this.dropIndicator_ = null;
/** @private {Object<string, function(!Event)>} */
this.documentListeners_ = null;
/** @private {?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 = Store.getInstance().data;
let draggedNodes = [];
if (isBookmarkItem(dragElement)) {
const displayingItems = assert(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) {
trackUpdatedItems();
}
chrome.bookmarkManagerPrivate.drop(
dropInfo.parentId, index,
shouldHighlight ? 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 = 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: 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 = 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 = 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(deselectItems());
if (!isBookmarkFolderNode(dragElement)) {
store.dispatch(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) => !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 = Store.getInstance().data;
let itemId = overElement.itemId;
// Drags aren't allowed onto the search result list.
if ((isBookmarkList(overElement) || isBookmarkItem(overElement)) &&
isShowingSearch(state)) {
return DropPosition.NONE;
}
if (isBookmarkList(overElement)) {
itemId = state.selectedFolder;
}
if (!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 = 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 = getPreviousElementSibling(overElement);
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) &&
hasChildFolders(overElement.itemId, state.nodes)) {
return validDropPositions;
}
const nextElement = getNextElementSibling(overElement);
// 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 = 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;
}
}