blob: 6539f75e8e07a4fbb568a69f350629678344a597 [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() {
83 assert(CommandManager.instance_ == null);
84 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) => {
129 if (e.path[0].tagName == 'INPUT') {
130 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:
235 return state.search.term == '' &&
236 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:
252 return itemIds.size == 1 && this.globalCanEdit_;
253 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:
263 return this.menuSource_ == MenuSource.ITEM && itemIds.size == 1 &&
264 this.getState().search.term != '' &&
265 !this.containsMatchingNode_(itemIds, function(node) {
266 return !node.parentId || node.parentId == ROOT_NODE_ID;
267 });
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 &&
302 state.prefs.incognitoAvailability != IncognitoAvailability.DISABLED;
303 case Command.SORT:
304 return this.canChangeList_() &&
305 state.nodes[state.selectedFolder].children.length > 1;
306 case Command.ADD_BOOKMARK:
307 case Command.ADD_FOLDER:
308 return this.canChangeList_();
309 case Command.IMPORT:
310 return this.globalCanEdit_;
311 case Command.PASTE:
312 return this.canPaste_;
313 default:
314 return true;
315 }
316 },
tsergeant77365182017-05-05 04:02:33317
rbpotterd66215c2019-11-20 07:20:36318 /**
319 * Returns whether the currently displayed bookmarks list can be changed.
320 * @private
321 * @return {boolean}
322 */
323 canChangeList_: function() {
324 const state = this.getState();
325 return state.search.term == '' &&
326 canReorderChildren(state, state.selectedFolder);
327 },
328
329 /**
330 * @param {Command} command
331 * @param {!Set<string>} itemIds
332 */
333 handle: function(command, itemIds) {
334 const state = this.getState();
335 switch (command) {
336 case Command.EDIT: {
337 const id = Array.from(itemIds)[0];
338 /** @type {!BookmarksEditDialogElement} */ (this.$.editDialog.get())
339 .showEditDialog(state.nodes[id]);
340 break;
tsergeant6c5ad90a2017-05-19 14:12:34341 }
rbpotterd66215c2019-11-20 07:20:36342 case Command.COPY_URL:
343 case Command.COPY: {
344 const idList = Array.from(itemIds);
345 chrome.bookmarkManagerPrivate.copy(idList, () => {
dpapad111a34902017-09-12 16:51:10346 let labelPromise;
rbpotterd66215c2019-11-20 07:20:36347 if (command == Command.COPY_URL) {
Christopher Lam75ca9102017-07-18 02:15:18348 labelPromise =
rbpotterd66215c2019-11-20 07:20:36349 Promise.resolve(loadTimeData.getString('toastUrlCopied'));
350 } else if (idList.length == 1) {
351 labelPromise =
352 Promise.resolve(loadTimeData.getString('toastItemCopied'));
Christopher Lam75ca9102017-07-18 02:15:18353 } else {
rbpottercd521682019-11-12 02:00:35354 labelPromise = this.browserProxy_.getPluralString(
rbpotterd66215c2019-11-20 07:20:36355 'toastItemsCopied', idList.length);
Christopher Lam75ca9102017-07-18 02:15:18356 }
357
rbpotterd66215c2019-11-20 07:20:36358 this.showTitleToast_(
359 labelPromise, state.nodes[idList[0]].title, false);
Hector Carmona79b07f02019-09-12 00:40:53360 });
rbpotterd66215c2019-11-20 07:20:36361 break;
362 }
363 case Command.SHOW_IN_FOLDER: {
364 const id = Array.from(itemIds)[0];
365 this.dispatch(
366 selectFolder(assert(state.nodes[id].parentId), state.nodes));
367 DialogFocusManager.getInstance().clearFocus();
368 this.fire('highlight-items', [id]);
369 break;
370 }
371 case Command.DELETE: {
372 const idList = Array.from(this.minimizeDeletionSet_(itemIds));
373 const title = state.nodes[idList[0]].title;
374 let labelPromise;
Hector Carmona79b07f02019-09-12 00:40:53375
rbpotterd66215c2019-11-20 07:20:36376 if (idList.length == 1) {
377 labelPromise =
378 Promise.resolve(loadTimeData.getString('toastItemDeleted'));
379 } else {
380 labelPromise = this.browserProxy_.getPluralString(
381 'toastItemsDeleted', idList.length);
382 }
tsergeantb7253e92017-07-04 02:59:22383
rbpotterd66215c2019-11-20 07:20:36384 chrome.bookmarkManagerPrivate.removeTrees(idList, () => {
385 this.showTitleToast_(labelPromise, title, true);
386 });
387 break;
388 }
389 case Command.UNDO:
390 chrome.bookmarkManagerPrivate.undo();
Esmael El-Moslimany544ed112019-11-27 00:28:35391 getToastManager().hide();
rbpotterd66215c2019-11-20 07:20:36392 break;
393 case Command.REDO:
394 chrome.bookmarkManagerPrivate.redo();
395 break;
396 case Command.OPEN_NEW_TAB:
397 case Command.OPEN_NEW_WINDOW:
398 case Command.OPEN_INCOGNITO:
399 this.openUrls_(this.expandUrls_(itemIds), command);
400 break;
401 case Command.OPEN:
402 if (this.isFolder_(itemIds)) {
403 const folderId = Array.from(itemIds)[0];
404 this.dispatch(selectFolder(folderId, state.nodes));
405 } else {
406 this.openUrls_(this.expandUrls_(itemIds), command);
407 }
408 break;
409 case Command.SELECT_ALL:
410 const displayedIds = getDisplayedList(state);
411 this.dispatch(selectAll(displayedIds, state));
412 break;
413 case Command.DESELECT_ALL:
414 this.dispatch(deselectItems());
415 break;
416 case Command.CUT:
417 chrome.bookmarkManagerPrivate.cut(Array.from(itemIds));
418 break;
419 case Command.PASTE:
420 const selectedFolder = state.selectedFolder;
421 const selectedItems = state.selection.items;
422 trackUpdatedItems();
423 chrome.bookmarkManagerPrivate.paste(
424 selectedFolder, Array.from(selectedItems), highlightUpdatedItems);
425 break;
426 case Command.SORT:
427 chrome.bookmarkManagerPrivate.sortChildren(
428 assert(state.selectedFolder));
Esmael El-Moslimany544ed112019-11-27 00:28:35429 getToastManager().querySelector('dom-if').if = true;
430 getToastManager().show(loadTimeData.getString('toastFolderSorted'));
431 this.fire('iron-announce', {
432 text: loadTimeData.getString('undoDescription'),
433 });
rbpotterd66215c2019-11-20 07:20:36434 break;
435 case Command.ADD_BOOKMARK:
436 /** @type {!BookmarksEditDialogElement} */ (this.$.editDialog.get())
437 .showAddDialog(false, assert(state.selectedFolder));
438 break;
439 case Command.ADD_FOLDER:
440 /** @type {!BookmarksEditDialogElement} */ (this.$.editDialog.get())
441 .showAddDialog(true, assert(state.selectedFolder));
442 break;
443 case Command.IMPORT:
444 chrome.bookmarks.import();
445 break;
446 case Command.EXPORT:
447 chrome.bookmarks.export();
448 break;
449 case Command.HELP_CENTER:
450 window.open('https://support.google.com/chrome/?p=bookmarks');
451 break;
452 default:
453 assert(false);
454 }
455 this.recordCommandHistogram_(
456 itemIds, 'BookmarkManager.CommandExecuted', command);
457 },
458
459 /**
460 * @param {!Event} e
461 * @param {!Set<string>} itemIds
462 * @return {boolean} True if the event was handled, triggering a keyboard
463 * shortcut.
464 */
465 handleKeyEvent: function(e, itemIds) {
466 for (const commandTuple of this.shortcuts_) {
467 const command = /** @type {Command} */ (commandTuple[0]);
468 const shortcut =
469 /** @type {KeyboardShortcutList} */ (commandTuple[1]);
470 if (shortcut.matchesEvent(e) && this.canExecute(command, itemIds)) {
471 this.handle(command, itemIds);
472
473 this.recordCommandHistogram_(
474 itemIds, 'BookmarkManager.CommandExecutedFromKeyboard', command);
475 e.stopPropagation();
476 e.preventDefault();
477 return true;
478 }
479 }
480
481 return false;
482 },
483
484 ////////////////////////////////////////////////////////////////////////////
485 // Private functions:
486
487 /**
488 * Register a keyboard shortcut for a command.
489 * @param {Command} command Command that the shortcut will trigger.
490 * @param {string} shortcut Keyboard shortcut, using the syntax of
491 * cr/ui/command.js.
492 * @param {string=} macShortcut If set, enables a replacement shortcut for
493 * Mac.
494 */
495 addShortcut_: function(command, shortcut, macShortcut) {
496 shortcut = (isMac && macShortcut) ? macShortcut : shortcut;
497 this.shortcuts_.set(command, new KeyboardShortcutList(shortcut));
498 },
499
500 /**
501 * Minimize the set of |itemIds| by removing any node which has an ancestor
502 * node already in the set. This ensures that instead of trying to delete
503 * both a node and its descendant, we will only try to delete the topmost
504 * node, preventing an error in the bookmarkManagerPrivate.removeTrees API
505 * call.
506 * @param {!Set<string>} itemIds
507 * @return {!Set<string>}
508 */
509 minimizeDeletionSet_: function(itemIds) {
510 const minimizedSet = new Set();
511 const nodes = this.getState().nodes;
512 itemIds.forEach(function(itemId) {
513 let currentId = itemId;
514 while (currentId != ROOT_NODE_ID) {
515 currentId = assert(nodes[currentId].parentId);
516 if (itemIds.has(currentId)) {
517 return;
518 }
519 }
520 minimizedSet.add(itemId);
521 });
522 return minimizedSet;
523 },
524
525 /**
526 * Open the given |urls| in response to a |command|. May show a confirmation
527 * dialog before opening large numbers of URLs.
528 * @param {!Array<string>} urls
529 * @param {Command} command
530 * @private
531 */
532 openUrls_: function(urls, command) {
533 assert(
534 command == Command.OPEN || command == Command.OPEN_NEW_TAB ||
535 command == Command.OPEN_NEW_WINDOW ||
536 command == Command.OPEN_INCOGNITO);
537
538 if (urls.length == 0) {
539 return;
540 }
541
542 const openUrlsCallback = function() {
543 const incognito = command == Command.OPEN_INCOGNITO;
544 if (command == Command.OPEN_NEW_WINDOW || incognito) {
545 chrome.windows.create({url: urls, incognito: incognito});
tsergeantb7253e92017-07-04 02:59:22546 } else {
rbpotterd66215c2019-11-20 07:20:36547 if (command == Command.OPEN) {
548 chrome.tabs.create({url: urls.shift(), active: true});
549 }
550 urls.forEach(function(url) {
551 chrome.tabs.create({url: url, active: false});
552 });
tsergeantb7253e92017-07-04 02:59:22553 }
rbpotterd66215c2019-11-20 07:20:36554 };
tsergeantb7253e92017-07-04 02:59:22555
rbpotterd66215c2019-11-20 07:20:36556 if (urls.length <= OPEN_CONFIRMATION_LIMIT) {
557 openUrlsCallback();
558 return;
559 }
tsergeantb7253e92017-07-04 02:59:22560
rbpotterd66215c2019-11-20 07:20:36561 this.confirmOpenCallback_ = openUrlsCallback;
562 const dialog = this.$.openDialog.get();
563 dialog.querySelector('[slot=body]').textContent =
564 loadTimeData.getStringF('openDialogBody', urls.length);
565
566 DialogFocusManager.getInstance().showDialog(this.$.openDialog.get());
567 },
568
569 /**
570 * Returns all URLs in the given set of nodes and their immediate children.
571 * Note that these will be ordered by insertion order into the |itemIds|
572 * set, and that it is possible to duplicate a URL by passing in both the
573 * parent ID and child ID.
574 * @param {!Set<string>} itemIds
575 * @return {!Array<string>}
576 * @private
577 */
578 expandUrls_: function(itemIds) {
579 const urls = [];
580 const nodes = this.getState().nodes;
581
582 itemIds.forEach(function(id) {
583 const node = nodes[id];
584 if (node.url) {
585 urls.push(node.url);
586 } else {
587 node.children.forEach(function(childId) {
588 const childNode = nodes[childId];
589 if (childNode.url) {
590 urls.push(childNode.url);
591 }
592 });
Esmael El-Moslimanyf50f28b2019-03-21 03:06:04593 }
rbpotterd66215c2019-11-20 07:20:36594 });
tsergeantb7253e92017-07-04 02:59:22595
rbpotterd66215c2019-11-20 07:20:36596 return urls;
597 },
tsergeantb7253e92017-07-04 02:59:22598
rbpotterd66215c2019-11-20 07:20:36599 /**
600 * @param {!Set<string>} itemIds
601 * @param {function(BookmarkNode):boolean} predicate
602 * @return {boolean} True if any node in |itemIds| returns true for
603 * |predicate|.
604 */
605 containsMatchingNode_: function(itemIds, predicate) {
606 const nodes = this.getState().nodes;
tsergeantb7253e92017-07-04 02:59:22607
rbpotterd66215c2019-11-20 07:20:36608 return Array.from(itemIds).some(function(id) {
609 return predicate(nodes[id]);
610 });
611 },
tsergeantb7253e92017-07-04 02:59:22612
rbpotterd66215c2019-11-20 07:20:36613 /**
614 * @param {!Set<string>} itemIds
615 * @return {boolean} True if |itemIds| is a single bookmark (non-folder)
616 * node.
617 * @private
618 */
619 isSingleBookmark_: function(itemIds) {
620 return itemIds.size == 1 &&
621 this.containsMatchingNode_(itemIds, function(node) {
622 return !!node.url;
623 });
624 },
tsergeant2db36262017-05-15 02:47:53625
rbpotterd66215c2019-11-20 07:20:36626 /**
627 * @param {!Set<string>} itemIds
628 * @return {boolean}
629 * @private
630 */
631 isFolder_: function(itemIds) {
632 return itemIds.size == 1 &&
633 this.containsMatchingNode_(itemIds, node => !node.url);
634 },
tsergeant2db36262017-05-15 02:47:53635
rbpotterd66215c2019-11-20 07:20:36636 /**
637 * @param {Command} command
638 * @return {string}
639 * @private
640 */
641 getCommandLabel_: function(command) {
642 const multipleNodes = this.menuIds_.size > 1 ||
643 this.containsMatchingNode_(this.menuIds_, function(node) {
644 return !node.url;
645 });
646 let label;
647 switch (command) {
648 case Command.EDIT:
649 if (this.menuIds_.size != 1) {
650 return '';
651 }
tsergeant2db36262017-05-15 02:47:53652
rbpotterd66215c2019-11-20 07:20:36653 const id = Array.from(this.menuIds_)[0];
654 const itemUrl = this.getState().nodes[id].url;
655 label = itemUrl ? 'menuEdit' : 'menuRename';
656 break;
657 case Command.CUT:
658 label = 'menuCut';
659 break;
660 case Command.COPY:
661 label = 'menuCopy';
662 break;
663 case Command.COPY_URL:
664 label = 'menuCopyURL';
665 break;
666 case Command.PASTE:
667 label = 'menuPaste';
668 break;
669 case Command.DELETE:
670 label = 'menuDelete';
671 break;
672 case Command.SHOW_IN_FOLDER:
673 label = 'menuShowInFolder';
674 break;
675 case Command.OPEN_NEW_TAB:
676 label = multipleNodes ? 'menuOpenAllNewTab' : 'menuOpenNewTab';
677 break;
678 case Command.OPEN_NEW_WINDOW:
679 label = multipleNodes ? 'menuOpenAllNewWindow' : 'menuOpenNewWindow';
680 break;
681 case Command.OPEN_INCOGNITO:
682 label = multipleNodes ? 'menuOpenAllIncognito' : 'menuOpenIncognito';
683 break;
684 case Command.SORT:
685 label = 'menuSort';
686 break;
687 case Command.ADD_BOOKMARK:
688 label = 'menuAddBookmark';
689 break;
690 case Command.ADD_FOLDER:
691 label = 'menuAddFolder';
692 break;
693 case Command.IMPORT:
694 label = 'menuImport';
695 break;
696 case Command.EXPORT:
697 label = 'menuExport';
698 break;
699 case Command.HELP_CENTER:
700 label = 'menuHelpCenter';
701 break;
702 }
703 assert(label);
704
705 return loadTimeData.getString(assert(label));
706 },
707
708 /**
709 * @param {Command} command
710 * @return {string}
711 * @private
712 */
713 getCommandSublabel_: function(command) {
714 const multipleNodes = this.menuIds_.size > 1 ||
715 this.containsMatchingNode_(this.menuIds_, function(node) {
716 return !node.url;
717 });
718 switch (command) {
719 case Command.OPEN_NEW_TAB:
720 const urls = this.expandUrls_(this.menuIds_);
721 return multipleNodes && urls.length > 0 ? String(urls.length) : '';
722 default:
723 return '';
724 }
725 },
726
727 /** @private */
728 computeMenuCommands_: function() {
729 switch (this.menuSource_) {
730 case MenuSource.ITEM:
731 case MenuSource.TREE:
732 return [
733 Command.EDIT,
734 Command.SHOW_IN_FOLDER,
735 Command.DELETE,
736 // <hr>
737 Command.CUT,
738 Command.COPY,
739 Command.COPY_URL,
740 Command.PASTE,
741 // <hr>
742 Command.OPEN_NEW_TAB,
743 Command.OPEN_NEW_WINDOW,
744 Command.OPEN_INCOGNITO,
745 ];
746 case MenuSource.TOOLBAR:
747 return [
748 Command.SORT,
749 // <hr>
750 Command.ADD_BOOKMARK,
751 Command.ADD_FOLDER,
752 // <hr>
753 Command.IMPORT,
754 Command.EXPORT,
755 // <hr>
756 Command.HELP_CENTER,
757 ];
758 case MenuSource.LIST:
759 return [
760 Command.ADD_BOOKMARK,
761 Command.ADD_FOLDER,
762 ];
763 case MenuSource.NONE:
764 return [];
765 }
766 assert(false);
767 },
768
769 /**
770 * @return {boolean}
771 * @private
772 */
773 computeHasAnySublabel_: function() {
774 if (this.menuIds_ == undefined || this.menuCommands_ == undefined) {
775 return false;
776 }
777
778 return this.menuCommands_.some(
779 (command) => this.getCommandSublabel_(command) != '');
780 },
781
782 /**
783 * @param {Command} command
784 * @param {!Set<string>} itemIds
785 * @return {boolean}
786 * @private
787 */
788 showDividerAfter_: function(command, itemIds) {
789 switch (command) {
790 case Command.SORT:
791 case Command.ADD_FOLDER:
792 case Command.EXPORT:
793 return this.menuSource_ == MenuSource.TOOLBAR;
794 case Command.DELETE:
795 return this.globalCanEdit_;
796 case Command.PASTE:
797 return this.globalCanEdit_ || this.isSingleBookmark_(itemIds);
798 }
799 return false;
800 },
801
802 /**
803 * @param {!Set<string>} itemIds
804 * @param {string} histogram
805 * @param {number} command
806 * @private
807 */
808 recordCommandHistogram_: function(itemIds, histogram, command) {
809 if (command == Command.OPEN) {
810 command =
811 this.isFolder_(itemIds) ? Command.OPEN_FOLDER : Command.OPEN_BOOKMARK;
812 }
813
814 this.browserProxy_.recordInHistogram(histogram, command, Command.MAX_VALUE);
815 },
816
817 /**
818 * Show a toast with a bookmark |title| inserted into a label, with the
819 * title ellipsised if necessary.
820 * @param {!Promise<string>} labelPromise Promise which resolves with the
821 * label for the toast.
822 * @param {string} title Bookmark title to insert.
823 * @param {boolean} canUndo If true, shows an undo button in the toast.
824 * @private
825 */
826 showTitleToast_: async function(labelPromise, title, canUndo) {
827 const label = await labelPromise;
828 const pieces =
829 loadTimeData.getSubstitutedStringPieces(label, title).map(function(p) {
830 // Make the bookmark name collapsible.
831 p.collapsible = !!p.arg;
832 return p;
833 });
Esmael El-Moslimany544ed112019-11-27 00:28:35834 getToastManager().querySelector('dom-if').if = canUndo;
835 getToastManager().showForStringPieces(pieces);
836 if (canUndo) {
837 this.fire('iron-announce', {
838 text: loadTimeData.getString('undoDescription'),
839 });
840 }
rbpotterd66215c2019-11-20 07:20:36841 },
842
843 /**
844 * @param {number} targetId
845 * @private
846 */
847 updateCanPaste_: function(targetId) {
848 return new Promise(resolve => {
849 chrome.bookmarkManagerPrivate.canPaste(`${targetId}`, result => {
850 this.canPaste_ = result;
851 resolve();
852 });
853 });
854 },
855
856 ////////////////////////////////////////////////////////////////////////////
857 // Event handlers:
858
859 /**
860 * @param {Event} e
861 * @private
862 */
863 onOpenCommandMenu_: async function(e) {
864 await this.updateCanPaste_(e.detail.source);
865 if (e.detail.targetElement) {
866 this.openCommandMenuAtElement(e.detail.targetElement, e.detail.source);
867 } else {
868 this.openCommandMenuAtPosition(e.detail.x, e.detail.y, e.detail.source);
869 }
870 this.browserProxy_.recordInHistogram(
871 'BookmarkManager.CommandMenuOpened', e.detail.source,
872 MenuSource.NUM_VALUES);
873 },
874
875 /**
876 * @param {Event} e
877 * @private
878 */
879 onCommandClick_: function(e) {
880 this.handle(
881 /** @type {Command} */ (
882 Number(e.currentTarget.getAttribute('command'))),
883 assert(this.menuIds_));
884 this.closeCommandMenu();
885 },
886
887 /**
888 * @param {!Event} e
889 * @private
890 */
891 onKeydown_: function(e) {
892 const path = e.composedPath();
893 if (path[0].tagName == 'INPUT') {
894 return;
895 }
896 if ((e.target == document.body ||
897 path.some(el => el.tagName == 'BOOKMARKS-TOOLBAR')) &&
898 !DialogFocusManager.getInstance().hasOpenDialog()) {
899 this.handleKeyEvent(e, this.getState().selection.items);
900 }
901 },
902
903 /**
904 * Close the menu on mousedown so clicks can propagate to the underlying UI.
905 * This allows the user to right click the list while a context menu is
906 * showing and get another context menu.
907 * @param {Event} e
908 * @private
909 */
910 onMenuMousedown_: function(e) {
911 if (e.path[0].tagName != 'DIALOG') {
912 return;
913 }
914
915 this.closeCommandMenu();
916 },
917
918 /** @private */
919 onOpenCancelTap_: function() {
920 this.$.openDialog.get().cancel();
921 },
922
923 /** @private */
924 onOpenConfirmTap_: function() {
925 this.confirmOpenCallback_();
926 this.$.openDialog.get().close();
927 },
928});
929
930/** @private {CommandManager} */
931CommandManager.instance_ = null;
932
933/** @return {!CommandManager} */
934CommandManager.getInstance = function() {
935 return assert(CommandManager.instance_);
936};