blob: f789ceefcc3b478759bdfe26c61eea0a082a2dbe [file] [log] [blame]
tsergeant77365182017-05-05 04:02:331// Copyright 2017 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
tsergeant2db36262017-05-15 02:47:535/**
6 * @fileoverview Element which shows context menus and handles keyboard
7 * shortcuts.
8 */
rbpotterf0aa38c2019-11-19 01:21:319import 'chrome://resources/cr_elements/cr_action_menu/cr_action_menu.m.js';
10import 'chrome://resources/cr_elements/cr_button/cr_button.m.js';
11import 'chrome://resources/cr_elements/cr_lazy_render/cr_lazy_render.m.js';
12import 'chrome://resources/cr_elements/shared_vars_css.m.js';
rbpotterf0aa38c2019-11-19 01:21:3113import 'chrome://resources/polymer/v3_0/iron-a11y-keys-behavior/iron-a11y-keys-behavior.js';
rbpotterf0aa38c2019-11-19 01:21:3114import './edit_dialog.js';
15import './shared_style.js';
rbpotterf0aa38c2019-11-19 01:21:3116import './strings.m.js';
rbpotterd66215c2019-11-20 07:20:3617
Esmael El-Moslimany544ed112019-11-27 00:28:3518import {getToastManager} from 'chrome://resources/cr_elements/cr_toast/cr_toast_manager.m.js';
rbpotterd66215c2019-11-20 07:20:3619import {assert} from 'chrome://resources/js/assert.m.js';
20import {isMac} from 'chrome://resources/js/cr.m.js';
21import {KeyboardShortcutList} from 'chrome://resources/js/cr/ui/keyboard_shortcut_list.m.js';
22import {loadTimeData} from 'chrome://resources/js/load_time_data.m.js';
Esmael El-Moslimany544ed112019-11-27 00:28:3523import {IronA11yAnnouncer} from 'chrome://resources/polymer/v3_0/iron-a11y-announcer/iron-a11y-announcer.js';
24import {afterNextRender, flush, html, Polymer} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
rbpotterd66215c2019-11-20 07:20:3625
26import {deselectItems, selectAll, selectFolder} from './actions.js';
27import {highlightUpdatedItems, trackUpdatedItems} from './api_listener.js';
28import {BrowserProxy} from './browser_proxy.js';
29import {Command, IncognitoAvailability, MenuSource, OPEN_CONFIRMATION_LIMIT, ROOT_NODE_ID} from './constants.js';
30import {DialogFocusManager} from './dialog_focus_manager.js';
31import {StoreClient} from './store_client.js';
rbpotterf0aa38c2019-11-19 01:21:3132import {BookmarkNode} from './types.js';
33import {canEditNode, canReorderChildren, getDisplayedList} from './util.js';
rbpotterf0aa38c2019-11-19 01:21:3134
rbpotterd66215c2019-11-20 07:20:3635export const CommandManager = Polymer({
36 is: 'bookmarks-command-manager',
tsergeant77365182017-05-05 04:02:3337
rbpotterd66215c2019-11-20 07:20:3638 _template: html`{__html_template__}`,
rbpotterf0aa38c2019-11-19 01:21:3139
rbpotterd66215c2019-11-20 07:20:3640 behaviors: [
41 StoreClient,
42 ],
tsergeant2db36262017-05-15 02:47:5343
rbpotterd66215c2019-11-20 07:20:3644 properties: {
45 /** @private {!Array<Command>} */
46 menuCommands_: {
47 type: Array,
48 computed: 'computeMenuCommands_(menuSource_)',
tsergeant13a466462017-05-15 01:21:0349 },
50
rbpotterd66215c2019-11-20 07:20:3651 /** @private {Set<string>} */
52 menuIds_: Object,
tsergeant7fb9e13f2017-06-26 06:35:1953
rbpotterd66215c2019-11-20 07:20:3654 /** @private */
55 hasAnySublabel_: {
56 type: Boolean,
57 reflectToAttribute: true,
58 computed: 'computeHasAnySublabel_(menuCommands_, menuIds_)',
59 },
tsergeant77365182017-05-05 04:02:3360
rbpotterd66215c2019-11-20 07:20:3661 /**
62 * Indicates where the context menu was opened from. Will be NONE if
63 * menu is not open, indicating that commands are from keyboard shortcuts
64 * or elsewhere in the UI.
65 * @private {MenuSource}
66 */
67 menuSource_: {
68 type: Number,
69 value: MenuSource.NONE,
70 },
rbpottercd521682019-11-12 02:00:3571
rbpotterd66215c2019-11-20 07:20:3672 /** @private */
73 canPaste_: Boolean,
tsergeant2437f992017-06-13 23:54:2974
rbpotterd66215c2019-11-20 07:20:3675 /** @private */
76 globalCanEdit_: Boolean,
77 },
tsergeant0292e51a2017-06-16 03:44:3578
rbpotterd66215c2019-11-20 07:20:3679 /** @private {?Function} */
80 confirmOpenCallback_: null,
tsergeant0292e51a2017-06-16 03:44:3581
rbpotterd66215c2019-11-20 07:20:3682 attached: function() {
Demetrios Papadopoulos5490c5e2019-12-12 22:24:5583 assert(CommandManager.instance_ === null);
rbpotterd66215c2019-11-20 07:20:3684 CommandManager.instance_ = this;
tsergeant0292e51a2017-06-16 03:44:3585
rbpotterd66215c2019-11-20 07:20:3686 /** @private {!BrowserProxy} */
87 this.browserProxy_ = BrowserProxy.getInstance();
tsergeant679159f2017-06-16 06:58:4188
rbpotterd66215c2019-11-20 07:20:3689 this.watch('globalCanEdit_', state => state.prefs.canEdit);
90 this.updateFromStore();
tsergeantb7253e92017-07-04 02:59:2291
rbpotterd66215c2019-11-20 07:20:3692 /** @private {!Map<Command, KeyboardShortcutList>} */
93 this.shortcuts_ = new Map();
Christopher Lamf4a16fd2018-02-01 01:47:1194
rbpotterd66215c2019-11-20 07:20:3695 this.addShortcut_(Command.EDIT, 'F2', 'Enter');
96 this.addShortcut_(Command.DELETE, 'Delete', 'Delete Backspace');
Christopher Lamf4a16fd2018-02-01 01:47:1197
rbpotterd66215c2019-11-20 07:20:3698 this.addShortcut_(Command.OPEN, 'Enter', 'Meta|o');
99 this.addShortcut_(Command.OPEN_NEW_TAB, 'Ctrl|Enter', 'Meta|Enter');
100 this.addShortcut_(Command.OPEN_NEW_WINDOW, 'Shift|Enter');
Christopher Lamf4a16fd2018-02-01 01:47:11101
rbpotterd66215c2019-11-20 07:20:36102 // Note: the undo shortcut is also defined in bookmarks_ui.cc
103 // TODO(b/893033): de-duplicate shortcut by moving all shortcut
104 // definitions from JS to C++.
105 this.addShortcut_(Command.UNDO, 'Ctrl|z', 'Meta|z');
106 this.addShortcut_(Command.REDO, 'Ctrl|y Ctrl|Shift|Z', 'Meta|Shift|Z');
Christopher Lamf4a16fd2018-02-01 01:47:11107
rbpotterd66215c2019-11-20 07:20:36108 this.addShortcut_(Command.SELECT_ALL, 'Ctrl|a', 'Meta|a');
109 this.addShortcut_(Command.DESELECT_ALL, 'Escape');
110
111 this.addShortcut_(Command.CUT, 'Ctrl|x', 'Meta|x');
112 this.addShortcut_(Command.COPY, 'Ctrl|c', 'Meta|c');
113 this.addShortcut_(Command.PASTE, 'Ctrl|v', 'Meta|v');
114
115 /** @private {!Map<string, Function>} */
116 this.boundListeners_ = new Map();
117
118 const addDocumentListener = (eventName, handler) => {
119 assert(!this.boundListeners_.has(eventName));
120 const boundListener = handler.bind(this);
121 this.boundListeners_.set(eventName, boundListener);
122 document.addEventListener(eventName, boundListener);
123 };
124 addDocumentListener('open-command-menu', this.onOpenCommandMenu_);
125 addDocumentListener('keydown', this.onKeydown_);
126
127 const addDocumentListenerForCommand = (eventName, command) => {
128 addDocumentListener(eventName, (e) => {
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55129 if (e.path[0].tagName === 'INPUT') {
rbpotterd66215c2019-11-20 07:20:36130 return;
131 }
132
133 const items = this.getState().selection.items;
134 if (this.canExecute(command, items)) {
135 this.handle(command, items);
136 }
137 });
138 };
139 addDocumentListenerForCommand('command-undo', Command.UNDO);
140 addDocumentListenerForCommand('cut', Command.CUT);
141 addDocumentListenerForCommand('copy', Command.COPY);
142 addDocumentListenerForCommand('paste', Command.PASTE);
Esmael El-Moslimany544ed112019-11-27 00:28:35143
144 afterNextRender(this, function() {
145 IronA11yAnnouncer.requestAvailability();
146 });
rbpotterd66215c2019-11-20 07:20:36147 },
148
149 detached: function() {
150 CommandManager.instance_ = null;
151 this.boundListeners_.forEach(
152 (handler, eventName) =>
153 document.removeEventListener(eventName, handler));
154 },
155
156 /**
157 * Display the command context menu at (|x|, |y|) in window coordinates.
158 * Commands will execute on |items| if given, or on the currently selected
159 * items.
160 * @param {number} x
161 * @param {number} y
162 * @param {MenuSource} source
163 * @param {Set<string>=} items
164 */
165 openCommandMenuAtPosition: function(x, y, source, items) {
166 this.menuSource_ = source;
167 this.menuIds_ = items || this.getState().selection.items;
168
169 const dropdown =
170 /** @type {!CrActionMenuElement} */ (this.$.dropdown.get());
171 // Ensure that the menu is fully rendered before trying to position it.
172 flush();
173 DialogFocusManager.getInstance().showDialog(
174 dropdown.getDialog(), function() {
175 dropdown.showAtPosition({top: y, left: x});
Christopher Lamf4a16fd2018-02-01 01:47:11176 });
rbpotterd66215c2019-11-20 07:20:36177 },
tsergeant77365182017-05-05 04:02:33178
rbpotterd66215c2019-11-20 07:20:36179 /**
180 * Display the command context menu positioned to cover the |target|
181 * element. Commands will execute on the currently selected items.
182 * @param {!Element} target
183 * @param {MenuSource} source
184 */
185 openCommandMenuAtElement: function(target, source) {
186 this.menuSource_ = source;
187 this.menuIds_ = this.getState().selection.items;
tsergeant77365182017-05-05 04:02:33188
rbpotterd66215c2019-11-20 07:20:36189 const dropdown =
190 /** @type {!CrActionMenuElement} */ (this.$.dropdown.get());
191 // Ensure that the menu is fully rendered before trying to position it.
192 flush();
193 DialogFocusManager.getInstance().showDialog(
194 dropdown.getDialog(), function() {
195 dropdown.showAt(target);
196 });
197 },
calamity57b2e8fd2017-06-29 18:46:58198
rbpotterd66215c2019-11-20 07:20:36199 closeCommandMenu: function() {
200 this.menuIds_ = new Set();
201 this.menuSource_ = MenuSource.NONE;
202 /** @type {!CrActionMenuElement} */ (this.$.dropdown.get()).close();
203 },
tsergeant77365182017-05-05 04:02:33204
rbpotterd66215c2019-11-20 07:20:36205 ////////////////////////////////////////////////////////////////////////////
206 // Command handlers:
calamity57b2e8fd2017-06-29 18:46:58207
rbpotterd66215c2019-11-20 07:20:36208 /**
209 * Determine if the |command| can be executed with the given |itemIds|.
210 * Commands which appear in the context menu should be implemented
211 * separately using `isCommandVisible_` and `isCommandEnabled_`.
212 * @param {Command} command
213 * @param {!Set<string>} itemIds
214 * @return {boolean}
215 */
216 canExecute: function(command, itemIds) {
217 const state = this.getState();
218 switch (command) {
219 case Command.OPEN:
220 return itemIds.size > 0;
221 case Command.UNDO:
222 case Command.REDO:
223 return this.globalCanEdit_;
224 case Command.SELECT_ALL:
225 case Command.DESELECT_ALL:
226 return true;
227 case Command.COPY:
228 return itemIds.size > 0;
229 case Command.CUT:
230 return itemIds.size > 0 &&
231 !this.containsMatchingNode_(itemIds, function(node) {
232 return !canEditNode(state, node.id);
233 });
234 case Command.PASTE:
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55235 return state.search.term === '' &&
rbpotterd66215c2019-11-20 07:20:36236 canReorderChildren(state, state.selectedFolder);
237 default:
238 return this.isCommandVisible_(command, itemIds) &&
239 this.isCommandEnabled_(command, itemIds);
240 }
241 },
tsergeant77365182017-05-05 04:02:33242
rbpotterd66215c2019-11-20 07:20:36243 /**
244 * @param {Command} command
245 * @param {!Set<string>} itemIds
246 * @return {boolean} True if the command should be visible in the context
247 * menu.
248 */
249 isCommandVisible_: function(command, itemIds) {
250 switch (command) {
251 case Command.EDIT:
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55252 return itemIds.size === 1 && this.globalCanEdit_;
rbpotterd66215c2019-11-20 07:20:36253 case Command.PASTE:
254 return this.globalCanEdit_;
255 case Command.CUT:
256 case Command.COPY:
257 return itemIds.size >= 1 && this.globalCanEdit_;
258 case Command.COPY_URL:
259 return this.isSingleBookmark_(itemIds);
260 case Command.DELETE:
261 return itemIds.size > 0 && this.globalCanEdit_;
262 case Command.SHOW_IN_FOLDER:
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55263 return this.menuSource_ === MenuSource.ITEM && itemIds.size === 1 &&
264 this.getState().search.term !== '' &&
rbpotterd66215c2019-11-20 07:20:36265 !this.containsMatchingNode_(itemIds, function(node) {
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55266 return !node.parentId || node.parentId === ROOT_NODE_ID;
rbpotterd66215c2019-11-20 07:20:36267 });
268 case Command.OPEN_NEW_TAB:
269 case Command.OPEN_NEW_WINDOW:
270 case Command.OPEN_INCOGNITO:
271 return itemIds.size > 0;
272 case Command.ADD_BOOKMARK:
273 case Command.ADD_FOLDER:
274 case Command.SORT:
275 case Command.EXPORT:
276 case Command.IMPORT:
277 case Command.HELP_CENTER:
278 return true;
279 }
280 return assert(false);
281 },
tsergeant77365182017-05-05 04:02:33282
rbpotterd66215c2019-11-20 07:20:36283 /**
284 * @param {Command} command
285 * @param {!Set<string>} itemIds
286 * @return {boolean} True if the command should be clickable in the context
287 * menu.
288 */
289 isCommandEnabled_: function(command, itemIds) {
290 const state = this.getState();
291 switch (command) {
292 case Command.EDIT:
293 case Command.DELETE:
294 return !this.containsMatchingNode_(itemIds, function(node) {
295 return !canEditNode(state, node.id);
296 });
297 case Command.OPEN_NEW_TAB:
298 case Command.OPEN_NEW_WINDOW:
299 return this.expandUrls_(itemIds).length > 0;
300 case Command.OPEN_INCOGNITO:
301 return this.expandUrls_(itemIds).length > 0 &&
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55302 state.prefs.incognitoAvailability !==
303 IncognitoAvailability.DISABLED;
rbpotterd66215c2019-11-20 07:20:36304 case Command.SORT:
305 return this.canChangeList_() &&
306 state.nodes[state.selectedFolder].children.length > 1;
307 case Command.ADD_BOOKMARK:
308 case Command.ADD_FOLDER:
309 return this.canChangeList_();
310 case Command.IMPORT:
311 return this.globalCanEdit_;
312 case Command.PASTE:
313 return this.canPaste_;
314 default:
315 return true;
316 }
317 },
tsergeant77365182017-05-05 04:02:33318
rbpotterd66215c2019-11-20 07:20:36319 /**
320 * Returns whether the currently displayed bookmarks list can be changed.
321 * @private
322 * @return {boolean}
323 */
324 canChangeList_: function() {
325 const state = this.getState();
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55326 return state.search.term === '' &&
rbpotterd66215c2019-11-20 07:20:36327 canReorderChildren(state, state.selectedFolder);
328 },
329
330 /**
331 * @param {Command} command
332 * @param {!Set<string>} itemIds
333 */
334 handle: function(command, itemIds) {
335 const state = this.getState();
336 switch (command) {
337 case Command.EDIT: {
338 const id = Array.from(itemIds)[0];
339 /** @type {!BookmarksEditDialogElement} */ (this.$.editDialog.get())
340 .showEditDialog(state.nodes[id]);
341 break;
tsergeant6c5ad90a2017-05-19 14:12:34342 }
rbpotterd66215c2019-11-20 07:20:36343 case Command.COPY_URL:
344 case Command.COPY: {
345 const idList = Array.from(itemIds);
346 chrome.bookmarkManagerPrivate.copy(idList, () => {
dpapad111a34902017-09-12 16:51:10347 let labelPromise;
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55348 if (command === Command.COPY_URL) {
Christopher Lam75ca9102017-07-18 02:15:18349 labelPromise =
rbpotterd66215c2019-11-20 07:20:36350 Promise.resolve(loadTimeData.getString('toastUrlCopied'));
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55351 } else if (idList.length === 1) {
rbpotterd66215c2019-11-20 07:20:36352 labelPromise =
353 Promise.resolve(loadTimeData.getString('toastItemCopied'));
Christopher Lam75ca9102017-07-18 02:15:18354 } else {
rbpottercd521682019-11-12 02:00:35355 labelPromise = this.browserProxy_.getPluralString(
rbpotterd66215c2019-11-20 07:20:36356 'toastItemsCopied', idList.length);
Christopher Lam75ca9102017-07-18 02:15:18357 }
358
rbpotterd66215c2019-11-20 07:20:36359 this.showTitleToast_(
360 labelPromise, state.nodes[idList[0]].title, false);
Hector Carmona79b07f02019-09-12 00:40:53361 });
rbpotterd66215c2019-11-20 07:20:36362 break;
363 }
364 case Command.SHOW_IN_FOLDER: {
365 const id = Array.from(itemIds)[0];
366 this.dispatch(
367 selectFolder(assert(state.nodes[id].parentId), state.nodes));
368 DialogFocusManager.getInstance().clearFocus();
369 this.fire('highlight-items', [id]);
370 break;
371 }
372 case Command.DELETE: {
373 const idList = Array.from(this.minimizeDeletionSet_(itemIds));
374 const title = state.nodes[idList[0]].title;
375 let labelPromise;
Hector Carmona79b07f02019-09-12 00:40:53376
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55377 if (idList.length === 1) {
rbpotterd66215c2019-11-20 07:20:36378 labelPromise =
379 Promise.resolve(loadTimeData.getString('toastItemDeleted'));
380 } else {
381 labelPromise = this.browserProxy_.getPluralString(
382 'toastItemsDeleted', idList.length);
383 }
tsergeantb7253e92017-07-04 02:59:22384
rbpotterd66215c2019-11-20 07:20:36385 chrome.bookmarkManagerPrivate.removeTrees(idList, () => {
386 this.showTitleToast_(labelPromise, title, true);
387 });
388 break;
389 }
390 case Command.UNDO:
391 chrome.bookmarkManagerPrivate.undo();
Esmael El-Moslimany544ed112019-11-27 00:28:35392 getToastManager().hide();
rbpotterd66215c2019-11-20 07:20:36393 break;
394 case Command.REDO:
395 chrome.bookmarkManagerPrivate.redo();
396 break;
397 case Command.OPEN_NEW_TAB:
398 case Command.OPEN_NEW_WINDOW:
399 case Command.OPEN_INCOGNITO:
400 this.openUrls_(this.expandUrls_(itemIds), command);
401 break;
402 case Command.OPEN:
403 if (this.isFolder_(itemIds)) {
404 const folderId = Array.from(itemIds)[0];
405 this.dispatch(selectFolder(folderId, state.nodes));
406 } else {
407 this.openUrls_(this.expandUrls_(itemIds), command);
408 }
409 break;
410 case Command.SELECT_ALL:
411 const displayedIds = getDisplayedList(state);
412 this.dispatch(selectAll(displayedIds, state));
413 break;
414 case Command.DESELECT_ALL:
415 this.dispatch(deselectItems());
416 break;
417 case Command.CUT:
418 chrome.bookmarkManagerPrivate.cut(Array.from(itemIds));
419 break;
420 case Command.PASTE:
421 const selectedFolder = state.selectedFolder;
422 const selectedItems = state.selection.items;
423 trackUpdatedItems();
424 chrome.bookmarkManagerPrivate.paste(
425 selectedFolder, Array.from(selectedItems), highlightUpdatedItems);
426 break;
427 case Command.SORT:
428 chrome.bookmarkManagerPrivate.sortChildren(
429 assert(state.selectedFolder));
Esmael El-Moslimany544ed112019-11-27 00:28:35430 getToastManager().querySelector('dom-if').if = true;
431 getToastManager().show(loadTimeData.getString('toastFolderSorted'));
432 this.fire('iron-announce', {
433 text: loadTimeData.getString('undoDescription'),
434 });
rbpotterd66215c2019-11-20 07:20:36435 break;
436 case Command.ADD_BOOKMARK:
437 /** @type {!BookmarksEditDialogElement} */ (this.$.editDialog.get())
438 .showAddDialog(false, assert(state.selectedFolder));
439 break;
440 case Command.ADD_FOLDER:
441 /** @type {!BookmarksEditDialogElement} */ (this.$.editDialog.get())
442 .showAddDialog(true, assert(state.selectedFolder));
443 break;
444 case Command.IMPORT:
445 chrome.bookmarks.import();
446 break;
447 case Command.EXPORT:
448 chrome.bookmarks.export();
449 break;
450 case Command.HELP_CENTER:
451 window.open('https://support.google.com/chrome/?p=bookmarks');
452 break;
453 default:
454 assert(false);
455 }
456 this.recordCommandHistogram_(
457 itemIds, 'BookmarkManager.CommandExecuted', command);
458 },
459
460 /**
461 * @param {!Event} e
462 * @param {!Set<string>} itemIds
463 * @return {boolean} True if the event was handled, triggering a keyboard
464 * shortcut.
465 */
466 handleKeyEvent: function(e, itemIds) {
467 for (const commandTuple of this.shortcuts_) {
468 const command = /** @type {Command} */ (commandTuple[0]);
469 const shortcut =
470 /** @type {KeyboardShortcutList} */ (commandTuple[1]);
471 if (shortcut.matchesEvent(e) && this.canExecute(command, itemIds)) {
472 this.handle(command, itemIds);
473
474 this.recordCommandHistogram_(
475 itemIds, 'BookmarkManager.CommandExecutedFromKeyboard', command);
476 e.stopPropagation();
477 e.preventDefault();
478 return true;
479 }
480 }
481
482 return false;
483 },
484
485 ////////////////////////////////////////////////////////////////////////////
486 // Private functions:
487
488 /**
489 * Register a keyboard shortcut for a command.
490 * @param {Command} command Command that the shortcut will trigger.
491 * @param {string} shortcut Keyboard shortcut, using the syntax of
492 * cr/ui/command.js.
493 * @param {string=} macShortcut If set, enables a replacement shortcut for
494 * Mac.
495 */
496 addShortcut_: function(command, shortcut, macShortcut) {
497 shortcut = (isMac && macShortcut) ? macShortcut : shortcut;
498 this.shortcuts_.set(command, new KeyboardShortcutList(shortcut));
499 },
500
501 /**
502 * Minimize the set of |itemIds| by removing any node which has an ancestor
503 * node already in the set. This ensures that instead of trying to delete
504 * both a node and its descendant, we will only try to delete the topmost
505 * node, preventing an error in the bookmarkManagerPrivate.removeTrees API
506 * call.
507 * @param {!Set<string>} itemIds
508 * @return {!Set<string>}
509 */
510 minimizeDeletionSet_: function(itemIds) {
511 const minimizedSet = new Set();
512 const nodes = this.getState().nodes;
513 itemIds.forEach(function(itemId) {
514 let currentId = itemId;
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55515 while (currentId !== ROOT_NODE_ID) {
rbpotterd66215c2019-11-20 07:20:36516 currentId = assert(nodes[currentId].parentId);
517 if (itemIds.has(currentId)) {
518 return;
519 }
520 }
521 minimizedSet.add(itemId);
522 });
523 return minimizedSet;
524 },
525
526 /**
527 * Open the given |urls| in response to a |command|. May show a confirmation
528 * dialog before opening large numbers of URLs.
529 * @param {!Array<string>} urls
530 * @param {Command} command
531 * @private
532 */
533 openUrls_: function(urls, command) {
534 assert(
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55535 command === Command.OPEN || command === Command.OPEN_NEW_TAB ||
536 command === Command.OPEN_NEW_WINDOW ||
537 command === Command.OPEN_INCOGNITO);
rbpotterd66215c2019-11-20 07:20:36538
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55539 if (urls.length === 0) {
rbpotterd66215c2019-11-20 07:20:36540 return;
541 }
542
543 const openUrlsCallback = function() {
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55544 const incognito = command === Command.OPEN_INCOGNITO;
545 if (command === Command.OPEN_NEW_WINDOW || incognito) {
rbpotterd66215c2019-11-20 07:20:36546 chrome.windows.create({url: urls, incognito: incognito});
tsergeantb7253e92017-07-04 02:59:22547 } else {
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55548 if (command === Command.OPEN) {
rbpotterd66215c2019-11-20 07:20:36549 chrome.tabs.create({url: urls.shift(), active: true});
550 }
551 urls.forEach(function(url) {
552 chrome.tabs.create({url: url, active: false});
553 });
tsergeantb7253e92017-07-04 02:59:22554 }
rbpotterd66215c2019-11-20 07:20:36555 };
tsergeantb7253e92017-07-04 02:59:22556
rbpotterd66215c2019-11-20 07:20:36557 if (urls.length <= OPEN_CONFIRMATION_LIMIT) {
558 openUrlsCallback();
559 return;
560 }
tsergeantb7253e92017-07-04 02:59:22561
rbpotterd66215c2019-11-20 07:20:36562 this.confirmOpenCallback_ = openUrlsCallback;
563 const dialog = this.$.openDialog.get();
564 dialog.querySelector('[slot=body]').textContent =
565 loadTimeData.getStringF('openDialogBody', urls.length);
566
567 DialogFocusManager.getInstance().showDialog(this.$.openDialog.get());
568 },
569
570 /**
571 * Returns all URLs in the given set of nodes and their immediate children.
572 * Note that these will be ordered by insertion order into the |itemIds|
573 * set, and that it is possible to duplicate a URL by passing in both the
574 * parent ID and child ID.
575 * @param {!Set<string>} itemIds
576 * @return {!Array<string>}
577 * @private
578 */
579 expandUrls_: function(itemIds) {
580 const urls = [];
581 const nodes = this.getState().nodes;
582
583 itemIds.forEach(function(id) {
584 const node = nodes[id];
585 if (node.url) {
586 urls.push(node.url);
587 } else {
588 node.children.forEach(function(childId) {
589 const childNode = nodes[childId];
590 if (childNode.url) {
591 urls.push(childNode.url);
592 }
593 });
Esmael El-Moslimanyf50f28b2019-03-21 03:06:04594 }
rbpotterd66215c2019-11-20 07:20:36595 });
tsergeantb7253e92017-07-04 02:59:22596
rbpotterd66215c2019-11-20 07:20:36597 return urls;
598 },
tsergeantb7253e92017-07-04 02:59:22599
rbpotterd66215c2019-11-20 07:20:36600 /**
601 * @param {!Set<string>} itemIds
602 * @param {function(BookmarkNode):boolean} predicate
603 * @return {boolean} True if any node in |itemIds| returns true for
604 * |predicate|.
605 */
606 containsMatchingNode_: function(itemIds, predicate) {
607 const nodes = this.getState().nodes;
tsergeantb7253e92017-07-04 02:59:22608
rbpotterd66215c2019-11-20 07:20:36609 return Array.from(itemIds).some(function(id) {
610 return predicate(nodes[id]);
611 });
612 },
tsergeantb7253e92017-07-04 02:59:22613
rbpotterd66215c2019-11-20 07:20:36614 /**
615 * @param {!Set<string>} itemIds
616 * @return {boolean} True if |itemIds| is a single bookmark (non-folder)
617 * node.
618 * @private
619 */
620 isSingleBookmark_: function(itemIds) {
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55621 return itemIds.size === 1 &&
rbpotterd66215c2019-11-20 07:20:36622 this.containsMatchingNode_(itemIds, function(node) {
623 return !!node.url;
624 });
625 },
tsergeant2db36262017-05-15 02:47:53626
rbpotterd66215c2019-11-20 07:20:36627 /**
628 * @param {!Set<string>} itemIds
629 * @return {boolean}
630 * @private
631 */
632 isFolder_: function(itemIds) {
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55633 return itemIds.size === 1 &&
rbpotterd66215c2019-11-20 07:20:36634 this.containsMatchingNode_(itemIds, node => !node.url);
635 },
tsergeant2db36262017-05-15 02:47:53636
rbpotterd66215c2019-11-20 07:20:36637 /**
638 * @param {Command} command
639 * @return {string}
640 * @private
641 */
642 getCommandLabel_: function(command) {
643 const multipleNodes = this.menuIds_.size > 1 ||
644 this.containsMatchingNode_(this.menuIds_, function(node) {
645 return !node.url;
646 });
647 let label;
648 switch (command) {
649 case Command.EDIT:
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55650 if (this.menuIds_.size !== 1) {
rbpotterd66215c2019-11-20 07:20:36651 return '';
652 }
tsergeant2db36262017-05-15 02:47:53653
rbpotterd66215c2019-11-20 07:20:36654 const id = Array.from(this.menuIds_)[0];
655 const itemUrl = this.getState().nodes[id].url;
656 label = itemUrl ? 'menuEdit' : 'menuRename';
657 break;
658 case Command.CUT:
659 label = 'menuCut';
660 break;
661 case Command.COPY:
662 label = 'menuCopy';
663 break;
664 case Command.COPY_URL:
665 label = 'menuCopyURL';
666 break;
667 case Command.PASTE:
668 label = 'menuPaste';
669 break;
670 case Command.DELETE:
671 label = 'menuDelete';
672 break;
673 case Command.SHOW_IN_FOLDER:
674 label = 'menuShowInFolder';
675 break;
676 case Command.OPEN_NEW_TAB:
677 label = multipleNodes ? 'menuOpenAllNewTab' : 'menuOpenNewTab';
678 break;
679 case Command.OPEN_NEW_WINDOW:
680 label = multipleNodes ? 'menuOpenAllNewWindow' : 'menuOpenNewWindow';
681 break;
682 case Command.OPEN_INCOGNITO:
683 label = multipleNodes ? 'menuOpenAllIncognito' : 'menuOpenIncognito';
684 break;
685 case Command.SORT:
686 label = 'menuSort';
687 break;
688 case Command.ADD_BOOKMARK:
689 label = 'menuAddBookmark';
690 break;
691 case Command.ADD_FOLDER:
692 label = 'menuAddFolder';
693 break;
694 case Command.IMPORT:
695 label = 'menuImport';
696 break;
697 case Command.EXPORT:
698 label = 'menuExport';
699 break;
700 case Command.HELP_CENTER:
701 label = 'menuHelpCenter';
702 break;
703 }
704 assert(label);
705
706 return loadTimeData.getString(assert(label));
707 },
708
709 /**
710 * @param {Command} command
711 * @return {string}
712 * @private
713 */
714 getCommandSublabel_: function(command) {
715 const multipleNodes = this.menuIds_.size > 1 ||
716 this.containsMatchingNode_(this.menuIds_, function(node) {
717 return !node.url;
718 });
719 switch (command) {
720 case Command.OPEN_NEW_TAB:
721 const urls = this.expandUrls_(this.menuIds_);
722 return multipleNodes && urls.length > 0 ? String(urls.length) : '';
723 default:
724 return '';
725 }
726 },
727
728 /** @private */
729 computeMenuCommands_: function() {
730 switch (this.menuSource_) {
731 case MenuSource.ITEM:
732 case MenuSource.TREE:
733 return [
734 Command.EDIT,
735 Command.SHOW_IN_FOLDER,
736 Command.DELETE,
737 // <hr>
738 Command.CUT,
739 Command.COPY,
740 Command.COPY_URL,
741 Command.PASTE,
742 // <hr>
743 Command.OPEN_NEW_TAB,
744 Command.OPEN_NEW_WINDOW,
745 Command.OPEN_INCOGNITO,
746 ];
747 case MenuSource.TOOLBAR:
748 return [
749 Command.SORT,
750 // <hr>
751 Command.ADD_BOOKMARK,
752 Command.ADD_FOLDER,
753 // <hr>
754 Command.IMPORT,
755 Command.EXPORT,
756 // <hr>
757 Command.HELP_CENTER,
758 ];
759 case MenuSource.LIST:
760 return [
761 Command.ADD_BOOKMARK,
762 Command.ADD_FOLDER,
763 ];
764 case MenuSource.NONE:
765 return [];
766 }
767 assert(false);
768 },
769
770 /**
771 * @return {boolean}
772 * @private
773 */
774 computeHasAnySublabel_: function() {
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55775 if (this.menuIds_ === undefined || this.menuCommands_ === undefined) {
rbpotterd66215c2019-11-20 07:20:36776 return false;
777 }
778
779 return this.menuCommands_.some(
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55780 (command) => this.getCommandSublabel_(command) !== '');
rbpotterd66215c2019-11-20 07:20:36781 },
782
783 /**
784 * @param {Command} command
785 * @param {!Set<string>} itemIds
786 * @return {boolean}
787 * @private
788 */
789 showDividerAfter_: function(command, itemIds) {
790 switch (command) {
791 case Command.SORT:
792 case Command.ADD_FOLDER:
793 case Command.EXPORT:
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55794 return this.menuSource_ === MenuSource.TOOLBAR;
rbpotterd66215c2019-11-20 07:20:36795 case Command.DELETE:
796 return this.globalCanEdit_;
797 case Command.PASTE:
798 return this.globalCanEdit_ || this.isSingleBookmark_(itemIds);
799 }
800 return false;
801 },
802
803 /**
804 * @param {!Set<string>} itemIds
805 * @param {string} histogram
806 * @param {number} command
807 * @private
808 */
809 recordCommandHistogram_: function(itemIds, histogram, command) {
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55810 if (command === Command.OPEN) {
rbpotterd66215c2019-11-20 07:20:36811 command =
812 this.isFolder_(itemIds) ? Command.OPEN_FOLDER : Command.OPEN_BOOKMARK;
813 }
814
815 this.browserProxy_.recordInHistogram(histogram, command, Command.MAX_VALUE);
816 },
817
818 /**
819 * Show a toast with a bookmark |title| inserted into a label, with the
820 * title ellipsised if necessary.
821 * @param {!Promise<string>} labelPromise Promise which resolves with the
822 * label for the toast.
823 * @param {string} title Bookmark title to insert.
824 * @param {boolean} canUndo If true, shows an undo button in the toast.
825 * @private
826 */
827 showTitleToast_: async function(labelPromise, title, canUndo) {
828 const label = await labelPromise;
829 const pieces =
830 loadTimeData.getSubstitutedStringPieces(label, title).map(function(p) {
831 // Make the bookmark name collapsible.
832 p.collapsible = !!p.arg;
833 return p;
834 });
Esmael El-Moslimany544ed112019-11-27 00:28:35835 getToastManager().querySelector('dom-if').if = canUndo;
836 getToastManager().showForStringPieces(pieces);
837 if (canUndo) {
838 this.fire('iron-announce', {
839 text: loadTimeData.getString('undoDescription'),
840 });
841 }
rbpotterd66215c2019-11-20 07:20:36842 },
843
844 /**
845 * @param {number} targetId
846 * @private
847 */
848 updateCanPaste_: function(targetId) {
849 return new Promise(resolve => {
850 chrome.bookmarkManagerPrivate.canPaste(`${targetId}`, result => {
851 this.canPaste_ = result;
852 resolve();
853 });
854 });
855 },
856
857 ////////////////////////////////////////////////////////////////////////////
858 // Event handlers:
859
860 /**
861 * @param {Event} e
862 * @private
863 */
864 onOpenCommandMenu_: async function(e) {
865 await this.updateCanPaste_(e.detail.source);
866 if (e.detail.targetElement) {
867 this.openCommandMenuAtElement(e.detail.targetElement, e.detail.source);
868 } else {
869 this.openCommandMenuAtPosition(e.detail.x, e.detail.y, e.detail.source);
870 }
871 this.browserProxy_.recordInHistogram(
872 'BookmarkManager.CommandMenuOpened', e.detail.source,
873 MenuSource.NUM_VALUES);
874 },
875
876 /**
877 * @param {Event} e
878 * @private
879 */
880 onCommandClick_: function(e) {
881 this.handle(
882 /** @type {Command} */ (
883 Number(e.currentTarget.getAttribute('command'))),
884 assert(this.menuIds_));
885 this.closeCommandMenu();
886 },
887
888 /**
889 * @param {!Event} e
890 * @private
891 */
892 onKeydown_: function(e) {
893 const path = e.composedPath();
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55894 if (path[0].tagName === 'INPUT') {
rbpotterd66215c2019-11-20 07:20:36895 return;
896 }
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55897 if ((e.target === document.body ||
898 path.some(el => el.tagName === 'BOOKMARKS-TOOLBAR')) &&
rbpotterd66215c2019-11-20 07:20:36899 !DialogFocusManager.getInstance().hasOpenDialog()) {
900 this.handleKeyEvent(e, this.getState().selection.items);
901 }
902 },
903
904 /**
905 * Close the menu on mousedown so clicks can propagate to the underlying UI.
906 * This allows the user to right click the list while a context menu is
907 * showing and get another context menu.
908 * @param {Event} e
909 * @private
910 */
911 onMenuMousedown_: function(e) {
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55912 if (e.path[0].tagName !== 'DIALOG') {
rbpotterd66215c2019-11-20 07:20:36913 return;
914 }
915
916 this.closeCommandMenu();
917 },
918
919 /** @private */
920 onOpenCancelTap_: function() {
921 this.$.openDialog.get().cancel();
922 },
923
924 /** @private */
925 onOpenConfirmTap_: function() {
926 this.confirmOpenCallback_();
927 this.$.openDialog.get().close();
928 },
929});
930
931/** @private {CommandManager} */
932CommandManager.instance_ = null;
933
934/** @return {!CommandManager} */
935CommandManager.getInstance = function() {
936 return assert(CommandManager.instance_);
937};