blob: ea223392a9873e0172707fdc507d3c7d403d964c [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';
dpapadc5e234c2020-09-23 21:31:5519import {assert, assertNotReached} from 'chrome://resources/js/assert.m.js';
rbpotterd66215c2019-11-20 07:20:3620import {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';
dpapad34538a92020-09-22 17:16:2323import {PluralStringProxyImpl} from 'chrome://resources/js/plural_string_proxy.js';
Esmael El-Moslimany544ed112019-11-27 00:28:3524import {IronA11yAnnouncer} from 'chrome://resources/polymer/v3_0/iron-a11y-announcer/iron-a11y-announcer.js';
25import {afterNextRender, flush, html, Polymer} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
rbpotterd66215c2019-11-20 07:20:3626
27import {deselectItems, selectAll, selectFolder} from './actions.js';
28import {highlightUpdatedItems, trackUpdatedItems} from './api_listener.js';
29import {BrowserProxy} from './browser_proxy.js';
30import {Command, IncognitoAvailability, MenuSource, OPEN_CONFIRMATION_LIMIT, ROOT_NODE_ID} from './constants.js';
31import {DialogFocusManager} from './dialog_focus_manager.js';
32import {StoreClient} from './store_client.js';
rbpotterf0aa38c2019-11-19 01:21:3133import {BookmarkNode} from './types.js';
34import {canEditNode, canReorderChildren, getDisplayedList} from './util.js';
rbpotterf0aa38c2019-11-19 01:21:3135
rbpotterd66215c2019-11-20 07:20:3636export const CommandManager = Polymer({
37 is: 'bookmarks-command-manager',
tsergeant77365182017-05-05 04:02:3338
rbpotterd66215c2019-11-20 07:20:3639 _template: html`{__html_template__}`,
rbpotterf0aa38c2019-11-19 01:21:3140
rbpotterd66215c2019-11-20 07:20:3641 behaviors: [
42 StoreClient,
43 ],
tsergeant2db36262017-05-15 02:47:5344
rbpotterd66215c2019-11-20 07:20:3645 properties: {
46 /** @private {!Array<Command>} */
47 menuCommands_: {
48 type: Array,
49 computed: 'computeMenuCommands_(menuSource_)',
tsergeant13a466462017-05-15 01:21:0350 },
51
rbpotterd66215c2019-11-20 07:20:3652 /** @private {Set<string>} */
53 menuIds_: Object,
tsergeant7fb9e13f2017-06-26 06:35:1954
rbpotterd66215c2019-11-20 07:20:3655 /**
56 * Indicates where the context menu was opened from. Will be NONE if
57 * menu is not open, indicating that commands are from keyboard shortcuts
58 * or elsewhere in the UI.
59 * @private {MenuSource}
60 */
61 menuSource_: {
62 type: Number,
63 value: MenuSource.NONE,
64 },
rbpottercd521682019-11-12 02:00:3565
rbpotterd66215c2019-11-20 07:20:3666 /** @private */
67 canPaste_: Boolean,
tsergeant2437f992017-06-13 23:54:2968
rbpotterd66215c2019-11-20 07:20:3669 /** @private */
70 globalCanEdit_: Boolean,
71 },
tsergeant0292e51a2017-06-16 03:44:3572
rbpotterd66215c2019-11-20 07:20:3673 /** @private {?Function} */
74 confirmOpenCallback_: null,
tsergeant0292e51a2017-06-16 03:44:3575
Dan Beama5cfc5f2020-01-14 05:58:1076 attached() {
Demetrios Papadopoulos5490c5e2019-12-12 22:24:5577 assert(CommandManager.instance_ === null);
rbpotterd66215c2019-11-20 07:20:3678 CommandManager.instance_ = this;
tsergeant0292e51a2017-06-16 03:44:3579
rbpotterd66215c2019-11-20 07:20:3680 /** @private {!BrowserProxy} */
81 this.browserProxy_ = BrowserProxy.getInstance();
tsergeant679159f2017-06-16 06:58:4182
rbpotterd66215c2019-11-20 07:20:3683 this.watch('globalCanEdit_', state => state.prefs.canEdit);
84 this.updateFromStore();
tsergeantb7253e92017-07-04 02:59:2285
rbpotterd66215c2019-11-20 07:20:3686 /** @private {!Map<Command, KeyboardShortcutList>} */
87 this.shortcuts_ = new Map();
Christopher Lamf4a16fd2018-02-01 01:47:1188
rbpotterd66215c2019-11-20 07:20:3689 this.addShortcut_(Command.EDIT, 'F2', 'Enter');
90 this.addShortcut_(Command.DELETE, 'Delete', 'Delete Backspace');
Christopher Lamf4a16fd2018-02-01 01:47:1191
rbpotterd66215c2019-11-20 07:20:3692 this.addShortcut_(Command.OPEN, 'Enter', 'Meta|o');
93 this.addShortcut_(Command.OPEN_NEW_TAB, 'Ctrl|Enter', 'Meta|Enter');
94 this.addShortcut_(Command.OPEN_NEW_WINDOW, 'Shift|Enter');
Christopher Lamf4a16fd2018-02-01 01:47:1195
rbpotterd66215c2019-11-20 07:20:3696 // Note: the undo shortcut is also defined in bookmarks_ui.cc
97 // TODO(b/893033): de-duplicate shortcut by moving all shortcut
98 // definitions from JS to C++.
99 this.addShortcut_(Command.UNDO, 'Ctrl|z', 'Meta|z');
100 this.addShortcut_(Command.REDO, 'Ctrl|y Ctrl|Shift|Z', 'Meta|Shift|Z');
Christopher Lamf4a16fd2018-02-01 01:47:11101
rbpotterd66215c2019-11-20 07:20:36102 this.addShortcut_(Command.SELECT_ALL, 'Ctrl|a', 'Meta|a');
103 this.addShortcut_(Command.DESELECT_ALL, 'Escape');
104
105 this.addShortcut_(Command.CUT, 'Ctrl|x', 'Meta|x');
106 this.addShortcut_(Command.COPY, 'Ctrl|c', 'Meta|c');
107 this.addShortcut_(Command.PASTE, 'Ctrl|v', 'Meta|v');
108
109 /** @private {!Map<string, Function>} */
110 this.boundListeners_ = new Map();
111
112 const addDocumentListener = (eventName, handler) => {
113 assert(!this.boundListeners_.has(eventName));
114 const boundListener = handler.bind(this);
115 this.boundListeners_.set(eventName, boundListener);
116 document.addEventListener(eventName, boundListener);
117 };
118 addDocumentListener('open-command-menu', this.onOpenCommandMenu_);
119 addDocumentListener('keydown', this.onKeydown_);
120
121 const addDocumentListenerForCommand = (eventName, command) => {
122 addDocumentListener(eventName, (e) => {
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55123 if (e.path[0].tagName === 'INPUT') {
rbpotterd66215c2019-11-20 07:20:36124 return;
125 }
126
127 const items = this.getState().selection.items;
128 if (this.canExecute(command, items)) {
129 this.handle(command, items);
130 }
131 });
132 };
133 addDocumentListenerForCommand('command-undo', Command.UNDO);
134 addDocumentListenerForCommand('cut', Command.CUT);
135 addDocumentListenerForCommand('copy', Command.COPY);
136 addDocumentListenerForCommand('paste', Command.PASTE);
Esmael El-Moslimany544ed112019-11-27 00:28:35137
138 afterNextRender(this, function() {
139 IronA11yAnnouncer.requestAvailability();
140 });
rbpotterd66215c2019-11-20 07:20:36141 },
142
Dan Beama5cfc5f2020-01-14 05:58:10143 detached() {
rbpotterd66215c2019-11-20 07:20:36144 CommandManager.instance_ = null;
145 this.boundListeners_.forEach(
146 (handler, eventName) =>
147 document.removeEventListener(eventName, handler));
148 },
149
150 /**
151 * Display the command context menu at (|x|, |y|) in window coordinates.
152 * Commands will execute on |items| if given, or on the currently selected
153 * items.
154 * @param {number} x
155 * @param {number} y
156 * @param {MenuSource} source
157 * @param {Set<string>=} items
158 */
Dan Beama5cfc5f2020-01-14 05:58:10159 openCommandMenuAtPosition(x, y, source, items) {
rbpotterd66215c2019-11-20 07:20:36160 this.menuSource_ = source;
161 this.menuIds_ = items || this.getState().selection.items;
162
163 const dropdown =
164 /** @type {!CrActionMenuElement} */ (this.$.dropdown.get());
165 // Ensure that the menu is fully rendered before trying to position it.
166 flush();
167 DialogFocusManager.getInstance().showDialog(
168 dropdown.getDialog(), function() {
169 dropdown.showAtPosition({top: y, left: x});
Christopher Lamf4a16fd2018-02-01 01:47:11170 });
rbpotterd66215c2019-11-20 07:20:36171 },
tsergeant77365182017-05-05 04:02:33172
rbpotterd66215c2019-11-20 07:20:36173 /**
174 * Display the command context menu positioned to cover the |target|
175 * element. Commands will execute on the currently selected items.
176 * @param {!Element} target
177 * @param {MenuSource} source
178 */
Dan Beama5cfc5f2020-01-14 05:58:10179 openCommandMenuAtElement(target, source) {
rbpotterd66215c2019-11-20 07:20:36180 this.menuSource_ = source;
181 this.menuIds_ = this.getState().selection.items;
tsergeant77365182017-05-05 04:02:33182
rbpotterd66215c2019-11-20 07:20:36183 const dropdown =
184 /** @type {!CrActionMenuElement} */ (this.$.dropdown.get());
185 // Ensure that the menu is fully rendered before trying to position it.
186 flush();
187 DialogFocusManager.getInstance().showDialog(
188 dropdown.getDialog(), function() {
189 dropdown.showAt(target);
190 });
191 },
calamity57b2e8fd2017-06-29 18:46:58192
Dan Beama5cfc5f2020-01-14 05:58:10193 closeCommandMenu() {
rbpotterd66215c2019-11-20 07:20:36194 this.menuIds_ = new Set();
195 this.menuSource_ = MenuSource.NONE;
196 /** @type {!CrActionMenuElement} */ (this.$.dropdown.get()).close();
197 },
tsergeant77365182017-05-05 04:02:33198
rbpotterd66215c2019-11-20 07:20:36199 ////////////////////////////////////////////////////////////////////////////
200 // Command handlers:
calamity57b2e8fd2017-06-29 18:46:58201
rbpotterd66215c2019-11-20 07:20:36202 /**
203 * Determine if the |command| can be executed with the given |itemIds|.
204 * Commands which appear in the context menu should be implemented
205 * separately using `isCommandVisible_` and `isCommandEnabled_`.
206 * @param {Command} command
207 * @param {!Set<string>} itemIds
208 * @return {boolean}
209 */
Dan Beama5cfc5f2020-01-14 05:58:10210 canExecute(command, itemIds) {
rbpotterd66215c2019-11-20 07:20:36211 const state = this.getState();
212 switch (command) {
213 case Command.OPEN:
214 return itemIds.size > 0;
215 case Command.UNDO:
216 case Command.REDO:
217 return this.globalCanEdit_;
218 case Command.SELECT_ALL:
219 case Command.DESELECT_ALL:
220 return true;
221 case Command.COPY:
222 return itemIds.size > 0;
223 case Command.CUT:
224 return itemIds.size > 0 &&
225 !this.containsMatchingNode_(itemIds, function(node) {
226 return !canEditNode(state, node.id);
227 });
228 case Command.PASTE:
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55229 return state.search.term === '' &&
rbpotterd66215c2019-11-20 07:20:36230 canReorderChildren(state, state.selectedFolder);
231 default:
232 return this.isCommandVisible_(command, itemIds) &&
233 this.isCommandEnabled_(command, itemIds);
234 }
235 },
tsergeant77365182017-05-05 04:02:33236
rbpotterd66215c2019-11-20 07:20:36237 /**
238 * @param {Command} command
239 * @param {!Set<string>} itemIds
240 * @return {boolean} True if the command should be visible in the context
241 * menu.
242 */
Dan Beama5cfc5f2020-01-14 05:58:10243 isCommandVisible_(command, itemIds) {
rbpotterd66215c2019-11-20 07:20:36244 switch (command) {
245 case Command.EDIT:
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55246 return itemIds.size === 1 && this.globalCanEdit_;
rbpotterd66215c2019-11-20 07:20:36247 case Command.PASTE:
248 return this.globalCanEdit_;
249 case Command.CUT:
250 case Command.COPY:
251 return itemIds.size >= 1 && this.globalCanEdit_;
252 case Command.COPY_URL:
253 return this.isSingleBookmark_(itemIds);
254 case Command.DELETE:
255 return itemIds.size > 0 && this.globalCanEdit_;
256 case Command.SHOW_IN_FOLDER:
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55257 return this.menuSource_ === MenuSource.ITEM && itemIds.size === 1 &&
258 this.getState().search.term !== '' &&
rbpotterd66215c2019-11-20 07:20:36259 !this.containsMatchingNode_(itemIds, function(node) {
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55260 return !node.parentId || node.parentId === ROOT_NODE_ID;
rbpotterd66215c2019-11-20 07:20:36261 });
262 case Command.OPEN_NEW_TAB:
263 case Command.OPEN_NEW_WINDOW:
264 case Command.OPEN_INCOGNITO:
265 return itemIds.size > 0;
266 case Command.ADD_BOOKMARK:
267 case Command.ADD_FOLDER:
268 case Command.SORT:
269 case Command.EXPORT:
270 case Command.IMPORT:
271 case Command.HELP_CENTER:
272 return true;
273 }
274 return assert(false);
275 },
tsergeant77365182017-05-05 04:02:33276
rbpotterd66215c2019-11-20 07:20:36277 /**
278 * @param {Command} command
279 * @param {!Set<string>} itemIds
280 * @return {boolean} True if the command should be clickable in the context
281 * menu.
282 */
Dan Beama5cfc5f2020-01-14 05:58:10283 isCommandEnabled_(command, itemIds) {
rbpotterd66215c2019-11-20 07:20:36284 const state = this.getState();
285 switch (command) {
286 case Command.EDIT:
287 case Command.DELETE:
288 return !this.containsMatchingNode_(itemIds, function(node) {
289 return !canEditNode(state, node.id);
290 });
291 case Command.OPEN_NEW_TAB:
292 case Command.OPEN_NEW_WINDOW:
293 return this.expandUrls_(itemIds).length > 0;
294 case Command.OPEN_INCOGNITO:
295 return this.expandUrls_(itemIds).length > 0 &&
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55296 state.prefs.incognitoAvailability !==
297 IncognitoAvailability.DISABLED;
rbpotterd66215c2019-11-20 07:20:36298 case Command.SORT:
299 return this.canChangeList_() &&
300 state.nodes[state.selectedFolder].children.length > 1;
301 case Command.ADD_BOOKMARK:
302 case Command.ADD_FOLDER:
303 return this.canChangeList_();
304 case Command.IMPORT:
305 return this.globalCanEdit_;
306 case Command.PASTE:
307 return this.canPaste_;
308 default:
309 return true;
310 }
311 },
tsergeant77365182017-05-05 04:02:33312
rbpotterd66215c2019-11-20 07:20:36313 /**
314 * Returns whether the currently displayed bookmarks list can be changed.
315 * @private
316 * @return {boolean}
317 */
Dan Beama5cfc5f2020-01-14 05:58:10318 canChangeList_() {
rbpotterd66215c2019-11-20 07:20:36319 const state = this.getState();
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55320 return state.search.term === '' &&
rbpotterd66215c2019-11-20 07:20:36321 canReorderChildren(state, state.selectedFolder);
322 },
323
324 /**
325 * @param {Command} command
326 * @param {!Set<string>} itemIds
327 */
Dan Beama5cfc5f2020-01-14 05:58:10328 handle(command, itemIds) {
rbpotterd66215c2019-11-20 07:20:36329 const state = this.getState();
330 switch (command) {
331 case Command.EDIT: {
332 const id = Array.from(itemIds)[0];
333 /** @type {!BookmarksEditDialogElement} */ (this.$.editDialog.get())
334 .showEditDialog(state.nodes[id]);
335 break;
tsergeant6c5ad90a2017-05-19 14:12:34336 }
rbpotterd66215c2019-11-20 07:20:36337 case Command.COPY_URL:
338 case Command.COPY: {
339 const idList = Array.from(itemIds);
340 chrome.bookmarkManagerPrivate.copy(idList, () => {
dpapad111a34902017-09-12 16:51:10341 let labelPromise;
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55342 if (command === Command.COPY_URL) {
Christopher Lam75ca9102017-07-18 02:15:18343 labelPromise =
rbpotterd66215c2019-11-20 07:20:36344 Promise.resolve(loadTimeData.getString('toastUrlCopied'));
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55345 } else if (idList.length === 1) {
rbpotterd66215c2019-11-20 07:20:36346 labelPromise =
347 Promise.resolve(loadTimeData.getString('toastItemCopied'));
Christopher Lam75ca9102017-07-18 02:15:18348 } else {
dpapad34538a92020-09-22 17:16:23349 labelPromise = PluralStringProxyImpl.getInstance().getPluralString(
rbpotterd66215c2019-11-20 07:20:36350 'toastItemsCopied', idList.length);
Christopher Lam75ca9102017-07-18 02:15:18351 }
352
rbpotterd66215c2019-11-20 07:20:36353 this.showTitleToast_(
354 labelPromise, state.nodes[idList[0]].title, false);
Hector Carmona79b07f02019-09-12 00:40:53355 });
rbpotterd66215c2019-11-20 07:20:36356 break;
357 }
358 case Command.SHOW_IN_FOLDER: {
359 const id = Array.from(itemIds)[0];
360 this.dispatch(
361 selectFolder(assert(state.nodes[id].parentId), state.nodes));
362 DialogFocusManager.getInstance().clearFocus();
363 this.fire('highlight-items', [id]);
364 break;
365 }
366 case Command.DELETE: {
367 const idList = Array.from(this.minimizeDeletionSet_(itemIds));
368 const title = state.nodes[idList[0]].title;
369 let labelPromise;
Hector Carmona79b07f02019-09-12 00:40:53370
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55371 if (idList.length === 1) {
rbpotterd66215c2019-11-20 07:20:36372 labelPromise =
373 Promise.resolve(loadTimeData.getString('toastItemDeleted'));
374 } else {
dpapad34538a92020-09-22 17:16:23375 labelPromise = PluralStringProxyImpl.getInstance().getPluralString(
rbpotterd66215c2019-11-20 07:20:36376 'toastItemsDeleted', idList.length);
377 }
tsergeantb7253e92017-07-04 02:59:22378
rbpotterd66215c2019-11-20 07:20:36379 chrome.bookmarkManagerPrivate.removeTrees(idList, () => {
380 this.showTitleToast_(labelPromise, title, true);
381 });
382 break;
383 }
384 case Command.UNDO:
385 chrome.bookmarkManagerPrivate.undo();
Esmael El-Moslimany544ed112019-11-27 00:28:35386 getToastManager().hide();
rbpotterd66215c2019-11-20 07:20:36387 break;
388 case Command.REDO:
389 chrome.bookmarkManagerPrivate.redo();
390 break;
391 case Command.OPEN_NEW_TAB:
392 case Command.OPEN_NEW_WINDOW:
393 case Command.OPEN_INCOGNITO:
394 this.openUrls_(this.expandUrls_(itemIds), command);
395 break;
396 case Command.OPEN:
397 if (this.isFolder_(itemIds)) {
398 const folderId = Array.from(itemIds)[0];
399 this.dispatch(selectFolder(folderId, state.nodes));
400 } else {
401 this.openUrls_(this.expandUrls_(itemIds), command);
402 }
403 break;
404 case Command.SELECT_ALL:
405 const displayedIds = getDisplayedList(state);
406 this.dispatch(selectAll(displayedIds, state));
407 break;
408 case Command.DESELECT_ALL:
409 this.dispatch(deselectItems());
John Lee3e46b102020-12-08 21:06:44410 IronA11yAnnouncer.requestAvailability();
411 this.fire('iron-announce', {
412 text: loadTimeData.getString('itemsUnselected'),
413 });
rbpotterd66215c2019-11-20 07:20:36414 break;
415 case Command.CUT:
416 chrome.bookmarkManagerPrivate.cut(Array.from(itemIds));
417 break;
418 case Command.PASTE:
419 const selectedFolder = state.selectedFolder;
420 const selectedItems = state.selection.items;
421 trackUpdatedItems();
422 chrome.bookmarkManagerPrivate.paste(
423 selectedFolder, Array.from(selectedItems), highlightUpdatedItems);
424 break;
425 case Command.SORT:
426 chrome.bookmarkManagerPrivate.sortChildren(
427 assert(state.selectedFolder));
Esmael El-Moslimany544ed112019-11-27 00:28:35428 getToastManager().querySelector('dom-if').if = true;
429 getToastManager().show(loadTimeData.getString('toastFolderSorted'));
rbpotterd66215c2019-11-20 07:20:36430 break;
431 case Command.ADD_BOOKMARK:
432 /** @type {!BookmarksEditDialogElement} */ (this.$.editDialog.get())
433 .showAddDialog(false, assert(state.selectedFolder));
434 break;
435 case Command.ADD_FOLDER:
436 /** @type {!BookmarksEditDialogElement} */ (this.$.editDialog.get())
437 .showAddDialog(true, assert(state.selectedFolder));
438 break;
439 case Command.IMPORT:
440 chrome.bookmarks.import();
441 break;
442 case Command.EXPORT:
443 chrome.bookmarks.export();
444 break;
445 case Command.HELP_CENTER:
446 window.open('https://support.google.com/chrome/?p=bookmarks');
447 break;
448 default:
449 assert(false);
450 }
451 this.recordCommandHistogram_(
452 itemIds, 'BookmarkManager.CommandExecuted', command);
453 },
454
455 /**
456 * @param {!Event} e
457 * @param {!Set<string>} itemIds
458 * @return {boolean} True if the event was handled, triggering a keyboard
459 * shortcut.
460 */
Dan Beama5cfc5f2020-01-14 05:58:10461 handleKeyEvent(e, itemIds) {
rbpotterd66215c2019-11-20 07:20:36462 for (const commandTuple of this.shortcuts_) {
463 const command = /** @type {Command} */ (commandTuple[0]);
464 const shortcut =
465 /** @type {KeyboardShortcutList} */ (commandTuple[1]);
466 if (shortcut.matchesEvent(e) && this.canExecute(command, itemIds)) {
467 this.handle(command, itemIds);
468
rbpotterd66215c2019-11-20 07:20:36469 e.stopPropagation();
470 e.preventDefault();
471 return true;
472 }
473 }
474
475 return false;
476 },
477
478 ////////////////////////////////////////////////////////////////////////////
479 // Private functions:
480
481 /**
482 * Register a keyboard shortcut for a command.
483 * @param {Command} command Command that the shortcut will trigger.
484 * @param {string} shortcut Keyboard shortcut, using the syntax of
485 * cr/ui/command.js.
486 * @param {string=} macShortcut If set, enables a replacement shortcut for
487 * Mac.
488 */
Dan Beama5cfc5f2020-01-14 05:58:10489 addShortcut_(command, shortcut, macShortcut) {
rbpotterd66215c2019-11-20 07:20:36490 shortcut = (isMac && macShortcut) ? macShortcut : shortcut;
491 this.shortcuts_.set(command, new KeyboardShortcutList(shortcut));
492 },
493
494 /**
495 * Minimize the set of |itemIds| by removing any node which has an ancestor
496 * node already in the set. This ensures that instead of trying to delete
497 * both a node and its descendant, we will only try to delete the topmost
498 * node, preventing an error in the bookmarkManagerPrivate.removeTrees API
499 * call.
500 * @param {!Set<string>} itemIds
501 * @return {!Set<string>}
502 */
Dan Beama5cfc5f2020-01-14 05:58:10503 minimizeDeletionSet_(itemIds) {
rbpotterd66215c2019-11-20 07:20:36504 const minimizedSet = new Set();
505 const nodes = this.getState().nodes;
506 itemIds.forEach(function(itemId) {
507 let currentId = itemId;
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55508 while (currentId !== ROOT_NODE_ID) {
rbpotterd66215c2019-11-20 07:20:36509 currentId = assert(nodes[currentId].parentId);
510 if (itemIds.has(currentId)) {
511 return;
512 }
513 }
514 minimizedSet.add(itemId);
515 });
516 return minimizedSet;
517 },
518
519 /**
520 * Open the given |urls| in response to a |command|. May show a confirmation
521 * dialog before opening large numbers of URLs.
522 * @param {!Array<string>} urls
523 * @param {Command} command
524 * @private
525 */
Dan Beama5cfc5f2020-01-14 05:58:10526 openUrls_(urls, command) {
rbpotterd66215c2019-11-20 07:20:36527 assert(
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55528 command === Command.OPEN || command === Command.OPEN_NEW_TAB ||
529 command === Command.OPEN_NEW_WINDOW ||
530 command === Command.OPEN_INCOGNITO);
rbpotterd66215c2019-11-20 07:20:36531
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55532 if (urls.length === 0) {
rbpotterd66215c2019-11-20 07:20:36533 return;
534 }
535
536 const openUrlsCallback = function() {
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55537 const incognito = command === Command.OPEN_INCOGNITO;
538 if (command === Command.OPEN_NEW_WINDOW || incognito) {
rbpotterd66215c2019-11-20 07:20:36539 chrome.windows.create({url: urls, incognito: incognito});
tsergeantb7253e92017-07-04 02:59:22540 } else {
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55541 if (command === Command.OPEN) {
rbpotterd66215c2019-11-20 07:20:36542 chrome.tabs.create({url: urls.shift(), active: true});
543 }
544 urls.forEach(function(url) {
545 chrome.tabs.create({url: url, active: false});
546 });
tsergeantb7253e92017-07-04 02:59:22547 }
rbpotterd66215c2019-11-20 07:20:36548 };
tsergeantb7253e92017-07-04 02:59:22549
rbpotterd66215c2019-11-20 07:20:36550 if (urls.length <= OPEN_CONFIRMATION_LIMIT) {
551 openUrlsCallback();
552 return;
553 }
tsergeantb7253e92017-07-04 02:59:22554
rbpotterd66215c2019-11-20 07:20:36555 this.confirmOpenCallback_ = openUrlsCallback;
556 const dialog = this.$.openDialog.get();
557 dialog.querySelector('[slot=body]').textContent =
558 loadTimeData.getStringF('openDialogBody', urls.length);
559
560 DialogFocusManager.getInstance().showDialog(this.$.openDialog.get());
561 },
562
563 /**
564 * Returns all URLs in the given set of nodes and their immediate children.
565 * Note that these will be ordered by insertion order into the |itemIds|
566 * set, and that it is possible to duplicate a URL by passing in both the
567 * parent ID and child ID.
568 * @param {!Set<string>} itemIds
569 * @return {!Array<string>}
570 * @private
571 */
Dan Beama5cfc5f2020-01-14 05:58:10572 expandUrls_(itemIds) {
rbpotterd66215c2019-11-20 07:20:36573 const urls = [];
574 const nodes = this.getState().nodes;
575
576 itemIds.forEach(function(id) {
577 const node = nodes[id];
578 if (node.url) {
579 urls.push(node.url);
580 } else {
581 node.children.forEach(function(childId) {
582 const childNode = nodes[childId];
583 if (childNode.url) {
584 urls.push(childNode.url);
585 }
586 });
Esmael El-Moslimanyf50f28b2019-03-21 03:06:04587 }
rbpotterd66215c2019-11-20 07:20:36588 });
tsergeantb7253e92017-07-04 02:59:22589
rbpotterd66215c2019-11-20 07:20:36590 return urls;
591 },
tsergeantb7253e92017-07-04 02:59:22592
rbpotterd66215c2019-11-20 07:20:36593 /**
594 * @param {!Set<string>} itemIds
595 * @param {function(BookmarkNode):boolean} predicate
596 * @return {boolean} True if any node in |itemIds| returns true for
597 * |predicate|.
598 */
Dan Beama5cfc5f2020-01-14 05:58:10599 containsMatchingNode_(itemIds, predicate) {
rbpotterd66215c2019-11-20 07:20:36600 const nodes = this.getState().nodes;
tsergeantb7253e92017-07-04 02:59:22601
rbpotterd66215c2019-11-20 07:20:36602 return Array.from(itemIds).some(function(id) {
603 return predicate(nodes[id]);
604 });
605 },
tsergeantb7253e92017-07-04 02:59:22606
rbpotterd66215c2019-11-20 07:20:36607 /**
608 * @param {!Set<string>} itemIds
609 * @return {boolean} True if |itemIds| is a single bookmark (non-folder)
610 * node.
611 * @private
612 */
Dan Beama5cfc5f2020-01-14 05:58:10613 isSingleBookmark_(itemIds) {
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55614 return itemIds.size === 1 &&
rbpotterd66215c2019-11-20 07:20:36615 this.containsMatchingNode_(itemIds, function(node) {
616 return !!node.url;
617 });
618 },
tsergeant2db36262017-05-15 02:47:53619
rbpotterd66215c2019-11-20 07:20:36620 /**
621 * @param {!Set<string>} itemIds
622 * @return {boolean}
623 * @private
624 */
Dan Beama5cfc5f2020-01-14 05:58:10625 isFolder_(itemIds) {
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55626 return itemIds.size === 1 &&
rbpotterd66215c2019-11-20 07:20:36627 this.containsMatchingNode_(itemIds, node => !node.url);
628 },
tsergeant2db36262017-05-15 02:47:53629
rbpotterd66215c2019-11-20 07:20:36630 /**
631 * @param {Command} command
632 * @return {string}
633 * @private
634 */
Dan Beama5cfc5f2020-01-14 05:58:10635 getCommandLabel_(command) {
dpapadc5e234c2020-09-23 21:31:55636 // Handle non-pluralized strings first.
637 let label = null;
rbpotterd66215c2019-11-20 07:20:36638 switch (command) {
639 case Command.EDIT:
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55640 if (this.menuIds_.size !== 1) {
rbpotterd66215c2019-11-20 07:20:36641 return '';
642 }
tsergeant2db36262017-05-15 02:47:53643
rbpotterd66215c2019-11-20 07:20:36644 const id = Array.from(this.menuIds_)[0];
645 const itemUrl = this.getState().nodes[id].url;
646 label = itemUrl ? 'menuEdit' : 'menuRename';
647 break;
648 case Command.CUT:
649 label = 'menuCut';
650 break;
651 case Command.COPY:
652 label = 'menuCopy';
653 break;
654 case Command.COPY_URL:
655 label = 'menuCopyURL';
656 break;
657 case Command.PASTE:
658 label = 'menuPaste';
659 break;
660 case Command.DELETE:
661 label = 'menuDelete';
662 break;
663 case Command.SHOW_IN_FOLDER:
664 label = 'menuShowInFolder';
665 break;
rbpotterd66215c2019-11-20 07:20:36666 case Command.SORT:
667 label = 'menuSort';
668 break;
669 case Command.ADD_BOOKMARK:
670 label = 'menuAddBookmark';
671 break;
672 case Command.ADD_FOLDER:
673 label = 'menuAddFolder';
674 break;
675 case Command.IMPORT:
676 label = 'menuImport';
677 break;
678 case Command.EXPORT:
679 label = 'menuExport';
680 break;
681 case Command.HELP_CENTER:
682 label = 'menuHelpCenter';
683 break;
684 }
dpapadc5e234c2020-09-23 21:31:55685 if (label !== null) {
686 return loadTimeData.getString(assert(label));
687 }
rbpotterd66215c2019-11-20 07:20:36688
dpapadc5e234c2020-09-23 21:31:55689 // Handle pluralized strings.
690 switch (command) {
691 case Command.OPEN_NEW_TAB:
692 return this.getPluralizedOpenAllString_(
693 'menuOpenAllNewTab', 'menuOpenNewTab',
694 'menuOpenAllNewTabWithCount');
695 case Command.OPEN_NEW_WINDOW:
696 return this.getPluralizedOpenAllString_(
697 'menuOpenAllNewWindow', 'menuOpenNewWindow',
698 'menuOpenAllNewWindowWithCount');
699 case Command.OPEN_INCOGNITO:
700 return this.getPluralizedOpenAllString_(
701 'menuOpenAllIncognito', 'menuOpenIncognito',
702 'menuOpenAllIncognitoWithCount');
703 }
704
705 assertNotReached();
706 return '';
707 },
708
709 /**
710 * @param {string} case0 String ID for the case of zero URLs.
711 * @param {string} case1 String ID for the case of 1 URL.
712 * @param {string} caseOther String ID for string that includes the URL count.
713 * @return {string}
714 * @private
715 */
716 getPluralizedOpenAllString_(case0, case1, caseOther) {
717 const multipleNodes = this.menuIds_.size > 1 ||
718 this.containsMatchingNode_(this.menuIds_, node => !node.url);
719
720 const urls = this.expandUrls_(this.menuIds_);
721 if (urls.length === 0) {
722 return loadTimeData.getStringF(case0, urls.length);
723 }
724
725 if (urls.length === 1 && !multipleNodes) {
726 return loadTimeData.getString(case1);
727 }
728
729 return loadTimeData.getStringF(caseOther, urls.length);
rbpotterd66215c2019-11-20 07:20:36730 },
731
732 /**
733 * @param {Command} command
734 * @return {string}
735 * @private
736 */
Dan Beama5cfc5f2020-01-14 05:58:10737 getCommandSublabel_(command) {
rbpotterd66215c2019-11-20 07:20:36738 const multipleNodes = this.menuIds_.size > 1 ||
739 this.containsMatchingNode_(this.menuIds_, function(node) {
740 return !node.url;
741 });
742 switch (command) {
743 case Command.OPEN_NEW_TAB:
744 const urls = this.expandUrls_(this.menuIds_);
745 return multipleNodes && urls.length > 0 ? String(urls.length) : '';
746 default:
747 return '';
748 }
749 },
750
751 /** @private */
Dan Beama5cfc5f2020-01-14 05:58:10752 computeMenuCommands_() {
rbpotterd66215c2019-11-20 07:20:36753 switch (this.menuSource_) {
754 case MenuSource.ITEM:
755 case MenuSource.TREE:
756 return [
757 Command.EDIT,
758 Command.SHOW_IN_FOLDER,
759 Command.DELETE,
760 // <hr>
761 Command.CUT,
762 Command.COPY,
763 Command.COPY_URL,
764 Command.PASTE,
765 // <hr>
766 Command.OPEN_NEW_TAB,
767 Command.OPEN_NEW_WINDOW,
768 Command.OPEN_INCOGNITO,
769 ];
770 case MenuSource.TOOLBAR:
771 return [
772 Command.SORT,
773 // <hr>
774 Command.ADD_BOOKMARK,
775 Command.ADD_FOLDER,
776 // <hr>
777 Command.IMPORT,
778 Command.EXPORT,
779 // <hr>
780 Command.HELP_CENTER,
781 ];
782 case MenuSource.LIST:
783 return [
784 Command.ADD_BOOKMARK,
785 Command.ADD_FOLDER,
786 ];
787 case MenuSource.NONE:
788 return [];
789 }
790 assert(false);
791 },
792
793 /**
rbpotterd66215c2019-11-20 07:20:36794 * @param {Command} command
795 * @param {!Set<string>} itemIds
796 * @return {boolean}
797 * @private
798 */
Dan Beama5cfc5f2020-01-14 05:58:10799 showDividerAfter_(command, itemIds) {
rbpotterd66215c2019-11-20 07:20:36800 switch (command) {
801 case Command.SORT:
802 case Command.ADD_FOLDER:
803 case Command.EXPORT:
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55804 return this.menuSource_ === MenuSource.TOOLBAR;
rbpotterd66215c2019-11-20 07:20:36805 case Command.DELETE:
806 return this.globalCanEdit_;
807 case Command.PASTE:
808 return this.globalCanEdit_ || this.isSingleBookmark_(itemIds);
809 }
810 return false;
811 },
812
813 /**
814 * @param {!Set<string>} itemIds
815 * @param {string} histogram
816 * @param {number} command
817 * @private
818 */
Dan Beama5cfc5f2020-01-14 05:58:10819 recordCommandHistogram_(itemIds, histogram, command) {
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55820 if (command === Command.OPEN) {
rbpotterd66215c2019-11-20 07:20:36821 command =
822 this.isFolder_(itemIds) ? Command.OPEN_FOLDER : Command.OPEN_BOOKMARK;
823 }
824
825 this.browserProxy_.recordInHistogram(histogram, command, Command.MAX_VALUE);
826 },
827
828 /**
829 * Show a toast with a bookmark |title| inserted into a label, with the
830 * title ellipsised if necessary.
831 * @param {!Promise<string>} labelPromise Promise which resolves with the
832 * label for the toast.
833 * @param {string} title Bookmark title to insert.
834 * @param {boolean} canUndo If true, shows an undo button in the toast.
835 * @private
836 */
837 showTitleToast_: async function(labelPromise, title, canUndo) {
838 const label = await labelPromise;
839 const pieces =
840 loadTimeData.getSubstitutedStringPieces(label, title).map(function(p) {
841 // Make the bookmark name collapsible.
842 p.collapsible = !!p.arg;
843 return p;
844 });
Esmael El-Moslimany544ed112019-11-27 00:28:35845 getToastManager().querySelector('dom-if').if = canUndo;
846 getToastManager().showForStringPieces(pieces);
rbpotterd66215c2019-11-20 07:20:36847 },
848
849 /**
850 * @param {number} targetId
851 * @private
852 */
Dan Beama5cfc5f2020-01-14 05:58:10853 updateCanPaste_(targetId) {
rbpotterd66215c2019-11-20 07:20:36854 return new Promise(resolve => {
855 chrome.bookmarkManagerPrivate.canPaste(`${targetId}`, result => {
856 this.canPaste_ = result;
857 resolve();
858 });
859 });
860 },
861
862 ////////////////////////////////////////////////////////////////////////////
863 // Event handlers:
864
865 /**
866 * @param {Event} e
867 * @private
868 */
869 onOpenCommandMenu_: async function(e) {
870 await this.updateCanPaste_(e.detail.source);
871 if (e.detail.targetElement) {
872 this.openCommandMenuAtElement(e.detail.targetElement, e.detail.source);
873 } else {
874 this.openCommandMenuAtPosition(e.detail.x, e.detail.y, e.detail.source);
875 }
876 this.browserProxy_.recordInHistogram(
877 'BookmarkManager.CommandMenuOpened', e.detail.source,
878 MenuSource.NUM_VALUES);
879 },
880
881 /**
882 * @param {Event} e
883 * @private
884 */
Dan Beama5cfc5f2020-01-14 05:58:10885 onCommandClick_(e) {
rbpotterd66215c2019-11-20 07:20:36886 this.handle(
887 /** @type {Command} */ (
888 Number(e.currentTarget.getAttribute('command'))),
889 assert(this.menuIds_));
890 this.closeCommandMenu();
891 },
892
893 /**
894 * @param {!Event} e
895 * @private
896 */
Dan Beama5cfc5f2020-01-14 05:58:10897 onKeydown_(e) {
rbpotterd66215c2019-11-20 07:20:36898 const path = e.composedPath();
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55899 if (path[0].tagName === 'INPUT') {
rbpotterd66215c2019-11-20 07:20:36900 return;
901 }
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55902 if ((e.target === document.body ||
903 path.some(el => el.tagName === 'BOOKMARKS-TOOLBAR')) &&
rbpotterd66215c2019-11-20 07:20:36904 !DialogFocusManager.getInstance().hasOpenDialog()) {
905 this.handleKeyEvent(e, this.getState().selection.items);
906 }
907 },
908
909 /**
910 * Close the menu on mousedown so clicks can propagate to the underlying UI.
911 * This allows the user to right click the list while a context menu is
912 * showing and get another context menu.
913 * @param {Event} e
914 * @private
915 */
Dan Beama5cfc5f2020-01-14 05:58:10916 onMenuMousedown_(e) {
Demetrios Papadopoulos5490c5e2019-12-12 22:24:55917 if (e.path[0].tagName !== 'DIALOG') {
rbpotterd66215c2019-11-20 07:20:36918 return;
919 }
920
921 this.closeCommandMenu();
922 },
923
924 /** @private */
Dan Beama5cfc5f2020-01-14 05:58:10925 onOpenCancelTap_() {
rbpotterd66215c2019-11-20 07:20:36926 this.$.openDialog.get().cancel();
927 },
928
929 /** @private */
Dan Beama5cfc5f2020-01-14 05:58:10930 onOpenConfirmTap_() {
rbpotterd66215c2019-11-20 07:20:36931 this.confirmOpenCallback_();
932 this.$.openDialog.get().close();
933 },
934});
935
936/** @private {CommandManager} */
937CommandManager.instance_ = null;
938
939/** @return {!CommandManager} */
940CommandManager.getInstance = function() {
941 return assert(CommandManager.instance_);
942};