blob: a9e4ede4656eef5c44ac5127d0dae8cb38a482bd [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.
/**
* @fileoverview Element which shows context menus and handles keyboard
* shortcuts.
*/
cr.define('bookmarks', function() {
const CommandManager = Polymer({
is: 'bookmarks-command-manager',
behaviors: [
bookmarks.StoreClient,
],
properties: {
/** @private {!Array<Command>} */
menuCommands_: {
type: Array,
computed: 'computeMenuCommands_(menuSource_)',
},
/** @private {Set<string>} */
menuIds_: Object,
/** @private */
hasAnySublabel_: {
type: Boolean,
reflectToAttribute: true,
computed: 'computeHasAnySublabel_(menuCommands_, menuIds_)',
},
/**
* Indicates where the context menu was opened from. Will be NONE if
* menu is not open, indicating that commands are from keyboard shortcuts
* or elsewhere in the UI.
* @private {MenuSource}
*/
menuSource_: {
type: Number,
value: MenuSource.NONE,
},
/** @private */
globalCanEdit_: Boolean,
},
/** @private {?Function} */
confirmOpenCallback_: null,
attached: function() {
assert(CommandManager.instance_ == null);
CommandManager.instance_ = this;
this.watch('globalCanEdit_', function(state) {
return state.prefs.canEdit;
});
this.updateFromStore();
/** @private {!Map<Command, cr.ui.KeyboardShortcutList>} */
this.shortcuts_ = new Map();
this.addShortcut_(Command.EDIT, 'F2', 'Enter');
this.addShortcut_(Command.DELETE, 'Delete', 'Delete Backspace');
this.addShortcut_(Command.OPEN, 'Enter', 'Meta|o');
this.addShortcut_(Command.OPEN_NEW_TAB, 'Ctrl|Enter', 'Meta|Enter');
this.addShortcut_(Command.OPEN_NEW_WINDOW, 'Shift|Enter');
// Note: the undo shortcut is also defined in md_bookmarks_ui.cc
// TODO(b/893033): de-duplicate shortcut by moving all shortcut
// definitions from JS to C++.
this.addShortcut_(Command.UNDO, 'Ctrl|z', 'Meta|z');
this.addShortcut_(Command.REDO, 'Ctrl|y Ctrl|Shift|Z', 'Meta|Shift|Z');
this.addShortcut_(Command.SELECT_ALL, 'Ctrl|a', 'Meta|a');
this.addShortcut_(Command.DESELECT_ALL, 'Escape');
this.addShortcut_(Command.CUT, 'Ctrl|x', 'Meta|x');
this.addShortcut_(Command.COPY, 'Ctrl|c', 'Meta|c');
this.addShortcut_(Command.PASTE, 'Ctrl|v', 'Meta|v');
/** @private {!Map<string, Function>} */
this.boundListeners_ = new Map();
const addDocumentListener = (eventName, handler) => {
assert(!this.boundListeners_.has(eventName));
const boundListener = handler.bind(this);
this.boundListeners_.set(eventName, boundListener);
document.addEventListener(eventName, boundListener);
};
addDocumentListener('open-command-menu', this.onOpenCommandMenu_);
addDocumentListener('keydown', this.onKeydown_);
const addDocumentListenerForCommand = (eventName, command) => {
addDocumentListener(eventName, (e) => {
if (e.path[0].tagName == 'INPUT')
return;
const items = this.getState().selection.items;
if (this.canExecute(command, items))
this.handle(command, items);
});
};
addDocumentListenerForCommand('command-undo', Command.UNDO);
addDocumentListenerForCommand('cut', Command.CUT);
addDocumentListenerForCommand('copy', Command.COPY);
addDocumentListenerForCommand('paste', Command.PASTE);
},
detached: function() {
CommandManager.instance_ = null;
this.boundListeners_.forEach(
(handler, eventName) =>
document.removeEventListener(eventName, handler));
},
/**
* Display the command context menu at (|x|, |y|) in window co-ordinates.
* Commands will execute on |items| if given, or on the currently selected
* items.
* @param {number} x
* @param {number} y
* @param {MenuSource} source
* @param {Set<string>=} items
*/
openCommandMenuAtPosition: function(x, y, source, items) {
this.menuSource_ = source;
this.menuIds_ = items || this.getState().selection.items;
const dropdown =
/** @type {!CrActionMenuElement} */ (this.$.dropdown.get());
// Ensure that the menu is fully rendered before trying to position it.
Polymer.dom.flush();
bookmarks.DialogFocusManager.getInstance().showDialog(
dropdown.getDialog(), function() {
dropdown.showAtPosition({top: y, left: x});
});
},
/**
* Display the command context menu positioned to cover the |target|
* element. Commands will execute on the currently selected items.
* @param {!Element} target
* @param {MenuSource} source
*/
openCommandMenuAtElement: function(target, source) {
this.menuSource_ = source;
this.menuIds_ = this.getState().selection.items;
const dropdown =
/** @type {!CrActionMenuElement} */ (this.$.dropdown.get());
// Ensure that the menu is fully rendered before trying to position it.
Polymer.dom.flush();
bookmarks.DialogFocusManager.getInstance().showDialog(
dropdown.getDialog(), function() {
dropdown.showAt(target);
});
},
closeCommandMenu: function() {
this.menuIds_ = new Set();
this.menuSource_ = MenuSource.NONE;
/** @type {!CrActionMenuElement} */ (this.$.dropdown.get()).close();
},
////////////////////////////////////////////////////////////////////////////
// Command handlers:
/**
* Determine if the |command| can be executed with the given |itemIds|.
* Commands which appear in the context menu should be implemented
* separately using `isCommandVisible_` and `isCommandEnabled_`.
* @param {Command} command
* @param {!Set<string>} itemIds
* @return {boolean}
*/
canExecute: function(command, itemIds) {
const state = this.getState();
switch (command) {
case Command.OPEN:
return itemIds.size > 0;
case Command.UNDO:
case Command.REDO:
return this.globalCanEdit_;
case Command.SELECT_ALL:
case Command.DESELECT_ALL:
return true;
case Command.COPY:
return itemIds.size > 0;
case Command.CUT:
return itemIds.size > 0 &&
!this.containsMatchingNode_(itemIds, function(node) {
return !bookmarks.util.canEditNode(state, node.id);
});
case Command.PASTE:
return state.search.term == '' &&
bookmarks.util.canReorderChildren(state, state.selectedFolder);
default:
return this.isCommandVisible_(command, itemIds) &&
this.isCommandEnabled_(command, itemIds);
}
},
/**
* @param {Command} command
* @param {!Set<string>} itemIds
* @return {boolean} True if the command should be visible in the context
* menu.
*/
isCommandVisible_: function(command, itemIds) {
switch (command) {
case Command.EDIT:
return itemIds.size == 1 && this.globalCanEdit_;
case Command.COPY_URL:
return this.isSingleBookmark_(itemIds);
case Command.DELETE:
return itemIds.size > 0 && this.globalCanEdit_;
case Command.SHOW_IN_FOLDER:
return this.menuSource_ == MenuSource.ITEM && itemIds.size == 1 &&
this.getState().search.term != '' &&
!this.containsMatchingNode_(itemIds, function(node) {
return !node.parentId || node.parentId == ROOT_NODE_ID;
});
case Command.OPEN_NEW_TAB:
case Command.OPEN_NEW_WINDOW:
case Command.OPEN_INCOGNITO:
return itemIds.size > 0;
case Command.ADD_BOOKMARK:
case Command.ADD_FOLDER:
case Command.SORT:
case Command.EXPORT:
case Command.IMPORT:
case Command.HELP_CENTER:
return true;
}
return assert(false);
},
/**
* @param {Command} command
* @param {!Set<string>} itemIds
* @return {boolean} True if the command should be clickable in the context
* menu.
*/
isCommandEnabled_: function(command, itemIds) {
const state = this.getState();
switch (command) {
case Command.EDIT:
case Command.DELETE:
return !this.containsMatchingNode_(itemIds, function(node) {
return !bookmarks.util.canEditNode(state, node.id);
});
case Command.OPEN_NEW_TAB:
case Command.OPEN_NEW_WINDOW:
return this.expandUrls_(itemIds).length > 0;
case Command.OPEN_INCOGNITO:
return this.expandUrls_(itemIds).length > 0 &&
state.prefs.incognitoAvailability !=
IncognitoAvailability.DISABLED;
case Command.SORT:
return this.canChangeList_() &&
state.nodes[state.selectedFolder].children.length > 1;
case Command.ADD_BOOKMARK:
case Command.ADD_FOLDER:
return this.canChangeList_();
case Command.IMPORT:
return this.globalCanEdit_;
default:
return true;
}
},
/**
* Returns whether the currently displayed bookmarks list can be changed.
* @private
* @return {boolean}
*/
canChangeList_: function() {
const state = this.getState();
return state.search.term == '' &&
bookmarks.util.canReorderChildren(state, state.selectedFolder);
},
/**
* @param {Command} command
* @param {!Set<string>} itemIds
*/
handle: function(command, itemIds) {
const state = this.getState();
switch (command) {
case Command.EDIT: {
const id = Array.from(itemIds)[0];
/** @type {!BookmarksEditDialogElement} */ (this.$.editDialog.get())
.showEditDialog(state.nodes[id]);
break;
}
case Command.COPY_URL:
case Command.COPY: {
const idList = Array.from(itemIds);
chrome.bookmarkManagerPrivate.copy(idList, () => {
let labelPromise;
if (command == Command.COPY_URL) {
labelPromise =
Promise.resolve(loadTimeData.getString('toastUrlCopied'));
} else if (idList.length == 1) {
labelPromise =
Promise.resolve(loadTimeData.getString('toastItemCopied'));
} else {
labelPromise = cr.sendWithPromise(
'getPluralString', 'toastItemsCopied', idList.length);
}
this.showTitleToast_(
labelPromise, state.nodes[idList[0]].title, false);
});
break;
}
case Command.SHOW_IN_FOLDER: {
const id = Array.from(itemIds)[0];
this.dispatch(bookmarks.actions.selectFolder(
assert(state.nodes[id].parentId), state.nodes));
bookmarks.DialogFocusManager.getInstance().clearFocus();
this.fire('highlight-items', [id]);
break;
}
case Command.DELETE: {
const idList = Array.from(this.minimizeDeletionSet_(itemIds));
const title = state.nodes[idList[0]].title;
let labelPromise;
if (idList.length == 1) {
labelPromise =
Promise.resolve(loadTimeData.getString('toastItemDeleted'));
} else {
labelPromise = cr.sendWithPromise(
'getPluralString', 'toastItemsDeleted', idList.length);
}
chrome.bookmarkManagerPrivate.removeTrees(idList, () => {
this.showTitleToast_(labelPromise, title, true);
});
break;
}
case Command.UNDO:
chrome.bookmarkManagerPrivate.undo();
bookmarks.ToastManager.getInstance().hide();
break;
case Command.REDO:
chrome.bookmarkManagerPrivate.redo();
break;
case Command.OPEN_NEW_TAB:
case Command.OPEN_NEW_WINDOW:
case Command.OPEN_INCOGNITO:
this.openUrls_(this.expandUrls_(itemIds), command);
break;
case Command.OPEN:
const isFolder = itemIds.size == 1 &&
this.containsMatchingNode_(itemIds, function(node) {
return !node.url;
});
if (isFolder) {
const folderId = Array.from(itemIds)[0];
this.dispatch(
bookmarks.actions.selectFolder(folderId, state.nodes));
} else {
this.openUrls_(this.expandUrls_(itemIds), command);
}
break;
case Command.SELECT_ALL:
const displayedIds = bookmarks.util.getDisplayedList(state);
this.dispatch(bookmarks.actions.selectAll(displayedIds, state));
break;
case Command.DESELECT_ALL:
this.dispatch(bookmarks.actions.deselectItems());
break;
case Command.CUT:
chrome.bookmarkManagerPrivate.cut(Array.from(itemIds));
break;
case Command.PASTE:
const selectedFolder = state.selectedFolder;
const selectedItems = state.selection.items;
bookmarks.ApiListener.trackUpdatedItems();
chrome.bookmarkManagerPrivate.paste(
selectedFolder, Array.from(selectedItems),
bookmarks.ApiListener.highlightUpdatedItems);
break;
case Command.SORT:
chrome.bookmarkManagerPrivate.sortChildren(
assert(state.selectedFolder));
bookmarks.ToastManager.getInstance().show(
loadTimeData.getString('toastFolderSorted'), true);
break;
case Command.ADD_BOOKMARK:
/** @type {!BookmarksEditDialogElement} */ (this.$.editDialog.get())
.showAddDialog(false, assert(state.selectedFolder));
break;
case Command.ADD_FOLDER:
/** @type {!BookmarksEditDialogElement} */ (this.$.editDialog.get())
.showAddDialog(true, assert(state.selectedFolder));
break;
case Command.IMPORT:
chrome.bookmarks.import();
break;
case Command.EXPORT:
chrome.bookmarks.export();
break;
case Command.HELP_CENTER:
window.open('https://support.google.com/chrome/?p=bookmarks');
break;
default:
assert(false);
}
bookmarks.util.recordEnumHistogram(
'BookmarkManager.CommandExecuted', command, Command.MAX_VALUE);
},
/**
* @param {!Event} e
* @param {!Set<string>} itemIds
* @return {boolean} True if the event was handled, triggering a keyboard
* shortcut.
*/
handleKeyEvent: function(e, itemIds) {
for (const commandTuple of this.shortcuts_) {
const command = /** @type {Command} */ (commandTuple[0]);
const shortcut =
/** @type {cr.ui.KeyboardShortcutList} */ (commandTuple[1]);
if (shortcut.matchesEvent(e) && this.canExecute(command, itemIds)) {
this.handle(command, itemIds);
bookmarks.util.recordEnumHistogram(
'BookmarkManager.CommandExecutedFromKeyboard', command,
Command.MAX_VALUE);
e.stopPropagation();
e.preventDefault();
return true;
}
}
return false;
},
////////////////////////////////////////////////////////////////////////////
// Private functions:
/**
* Register a keyboard shortcut for a command.
* @param {Command} command Command that the shortcut will trigger.
* @param {string} shortcut Keyboard shortcut, using the syntax of
* cr/ui/command.js.
* @param {string=} macShortcut If set, enables a replacement shortcut for
* Mac.
*/
addShortcut_: function(command, shortcut, macShortcut) {
shortcut = (cr.isMac && macShortcut) ? macShortcut : shortcut;
this.shortcuts_.set(command, new cr.ui.KeyboardShortcutList(shortcut));
},
/**
* Minimize the set of |itemIds| by removing any node which has an ancestor
* node already in the set. This ensures that instead of trying to delete
* both a node and its descendant, we will only try to delete the topmost
* node, preventing an error in the bookmarkManagerPrivate.removeTrees API
* call.
* @param {!Set<string>} itemIds
* @return {!Set<string>}
*/
minimizeDeletionSet_: function(itemIds) {
const minimizedSet = new Set();
const nodes = this.getState().nodes;
itemIds.forEach(function(itemId) {
let currentId = itemId;
while (currentId != ROOT_NODE_ID) {
currentId = assert(nodes[currentId].parentId);
if (itemIds.has(currentId))
return;
}
minimizedSet.add(itemId);
});
return minimizedSet;
},
/**
* Open the given |urls| in response to a |command|. May show a confirmation
* dialog before opening large numbers of URLs.
* @param {!Array<string>} urls
* @param {Command} command
* @private
*/
openUrls_: function(urls, command) {
assert(
command == Command.OPEN || command == Command.OPEN_NEW_TAB ||
command == Command.OPEN_NEW_WINDOW ||
command == Command.OPEN_INCOGNITO);
if (urls.length == 0)
return;
const openUrlsCallback = function() {
const incognito = command == Command.OPEN_INCOGNITO;
if (command == Command.OPEN_NEW_WINDOW || incognito) {
chrome.windows.create({url: urls, incognito: incognito});
} else {
if (command == Command.OPEN)
chrome.tabs.create({url: urls.shift(), active: true});
urls.forEach(function(url) {
chrome.tabs.create({url: url, active: false});
});
}
};
if (urls.length <= OPEN_CONFIRMATION_LIMIT) {
openUrlsCallback();
return;
}
this.confirmOpenCallback_ = openUrlsCallback;
const dialog = this.$.openDialog.get();
dialog.querySelector('[slot=body]').textContent =
loadTimeData.getStringF('openDialogBody', urls.length);
bookmarks.DialogFocusManager.getInstance().showDialog(
this.$.openDialog.get());
},
/**
* Returns all URLs in the given set of nodes and their immediate children.
* Note that these will be ordered by insertion order into the |itemIds|
* set, and that it is possible to duplicate a URL by passing in both the
* parent ID and child ID.
* @param {!Set<string>} itemIds
* @return {!Array<string>}
* @private
*/
expandUrls_: function(itemIds) {
const urls = [];
const nodes = this.getState().nodes;
itemIds.forEach(function(id) {
const node = nodes[id];
if (node.url) {
urls.push(node.url);
} else {
node.children.forEach(function(childId) {
const childNode = nodes[childId];
if (childNode.url)
urls.push(childNode.url);
});
}
});
return urls;
},
/**
* @param {!Set<string>} itemIds
* @param {function(BookmarkNode):boolean} predicate
* @return {boolean} True if any node in |itemIds| returns true for
* |predicate|.
*/
containsMatchingNode_: function(itemIds, predicate) {
const nodes = this.getState().nodes;
return Array.from(itemIds).some(function(id) {
return predicate(nodes[id]);
});
},
/**
* @param {!Set<string>} itemIds
* @return {boolean} True if |itemIds| is a single bookmark (non-folder)
* node.
*/
isSingleBookmark_: function(itemIds) {
return itemIds.size == 1 &&
this.containsMatchingNode_(itemIds, function(node) {
return !!node.url;
});
},
/**
* @param {Command} command
* @return {string}
* @private
*/
getCommandLabel_: function(command) {
const multipleNodes = this.menuIds_.size > 1 ||
this.containsMatchingNode_(this.menuIds_, function(node) {
return !node.url;
});
let label;
switch (command) {
case Command.EDIT:
if (this.menuIds_.size != 1)
return '';
const id = Array.from(this.menuIds_)[0];
const itemUrl = this.getState().nodes[id].url;
label = itemUrl ? 'menuEdit' : 'menuRename';
break;
case Command.COPY_URL:
label = 'menuCopyURL';
break;
case Command.DELETE:
label = 'menuDelete';
break;
case Command.SHOW_IN_FOLDER:
label = 'menuShowInFolder';
break;
case Command.OPEN_NEW_TAB:
label = multipleNodes ? 'menuOpenAllNewTab' : 'menuOpenNewTab';
break;
case Command.OPEN_NEW_WINDOW:
label = multipleNodes ? 'menuOpenAllNewWindow' : 'menuOpenNewWindow';
break;
case Command.OPEN_INCOGNITO:
label = multipleNodes ? 'menuOpenAllIncognito' : 'menuOpenIncognito';
break;
case Command.SORT:
label = 'menuSort';
break;
case Command.ADD_BOOKMARK:
label = 'menuAddBookmark';
break;
case Command.ADD_FOLDER:
label = 'menuAddFolder';
break;
case Command.IMPORT:
label = 'menuImport';
break;
case Command.EXPORT:
label = 'menuExport';
break;
case Command.HELP_CENTER:
label = 'menuHelpCenter';
break;
}
assert(label);
return loadTimeData.getString(assert(label));
},
/**
* @param {Command} command
* @return {string}
* @private
*/
getCommandSublabel_: function(command) {
const multipleNodes = this.menuIds_.size > 1 ||
this.containsMatchingNode_(this.menuIds_, function(node) {
return !node.url;
});
switch (command) {
case Command.OPEN_NEW_TAB:
const urls = this.expandUrls_(this.menuIds_);
return multipleNodes && urls.length > 0 ? String(urls.length) : '';
default:
return '';
}
},
/** @private */
computeMenuCommands_: function() {
switch (this.menuSource_) {
case MenuSource.ITEM:
case MenuSource.TREE:
return [
Command.EDIT,
Command.COPY_URL,
Command.SHOW_IN_FOLDER,
Command.DELETE,
// <hr>
Command.OPEN_NEW_TAB,
Command.OPEN_NEW_WINDOW,
Command.OPEN_INCOGNITO,
];
case MenuSource.TOOLBAR:
return [
Command.SORT,
// <hr>
Command.ADD_BOOKMARK,
Command.ADD_FOLDER,
// <hr>
Command.IMPORT,
Command.EXPORT,
// <hr>
Command.HELP_CENTER,
];
case MenuSource.LIST:
return [
Command.ADD_BOOKMARK,
Command.ADD_FOLDER,
];
case MenuSource.NONE:
return [];
}
assert(false);
},
/**
* @return {boolean}
* @private
*/
computeHasAnySublabel_: function() {
if (this.menuIds_ == undefined || this.menuCommands_ == undefined)
return false;
return this.menuCommands_.some(
(command) => this.getCommandSublabel_(command) != '');
},
/**
* @param {Command} command
* @return {boolean}
* @private
*/
showDividerAfter_: function(command, itemIds) {
return ((command == Command.SORT || command == Command.ADD_FOLDER ||
command == Command.EXPORT) &&
this.menuSource_ == MenuSource.TOOLBAR) ||
(command == Command.DELETE &&
(this.globalCanEdit_ || this.isSingleBookmark_(itemIds)));
},
/**
* Show a toast with a bookmark |title| inserted into a label, with the
* title ellipsised if necessary.
* @param {!Promise<string>} labelPromise Promise which resolves with the
* label for the toast.
* @param {string} title Bookmark title to insert.
* @param {boolean} canUndo If true, shows an undo button in the toast.
* @private
*/
showTitleToast_: async function(labelPromise, title, canUndo) {
const label = await labelPromise;
const pieces = loadTimeData.getSubstitutedStringPieces(label, title)
.map(function(p) {
// Make the bookmark name collapsible.
p.collapsible = !!p.arg;
return p;
});
bookmarks.ToastManager.getInstance().showForStringPieces(pieces, canUndo);
},
////////////////////////////////////////////////////////////////////////////
// Event handlers:
/**
* @param {Event} e
* @private
*/
onOpenCommandMenu_: function(e) {
if (e.detail.targetElement) {
this.openCommandMenuAtElement(e.detail.targetElement, e.detail.source);
} else {
this.openCommandMenuAtPosition(e.detail.x, e.detail.y, e.detail.source);
}
bookmarks.util.recordEnumHistogram(
'BookmarkManager.CommandMenuOpened', e.detail.source,
MenuSource.NUM_VALUES);
},
/**
* @param {Event} e
* @private
*/
onCommandClick_: function(e) {
this.handle(
/** @type {Command} */ (
Number(e.currentTarget.getAttribute('command'))),
assert(this.menuIds_));
this.closeCommandMenu();
},
/**
* @param {!Event} e
* @private
*/
onKeydown_: function(e) {
const selection = this.getState().selection.items;
if (e.target == document.body &&
!bookmarks.DialogFocusManager.getInstance().hasOpenDialog()) {
this.handleKeyEvent(e, selection);
}
},
/**
* Close the menu on mousedown so clicks can propagate to the underlying UI.
* This allows the user to right click the list while a context menu is
* showing and get another context menu.
* @param {Event} e
* @private
*/
onMenuMousedown_: function(e) {
if (e.path[0].tagName != 'DIALOG')
return;
this.closeCommandMenu();
},
/** @private */
onOpenCancelTap_: function() {
this.$.openDialog.get().cancel();
},
/** @private */
onOpenConfirmTap_: function() {
this.confirmOpenCallback_();
this.$.openDialog.get().close();
},
});
/** @private {bookmarks.CommandManager} */
CommandManager.instance_ = null;
/** @return {!bookmarks.CommandManager} */
CommandManager.getInstance = function() {
return assert(CommandManager.instance_);
};
return {
CommandManager: CommandManager,
};
});