blob: 24c26d51c673ca4bed26f2de7693e02bdd4bce12 [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 */
9cr.define('bookmarks', function() {
tsergeant77365182017-05-05 04:02:3310
tsergeant2db36262017-05-15 02:47:5311 var CommandManager = Polymer({
12 is: 'bookmarks-command-manager',
tsergeant77365182017-05-05 04:02:3313
tsergeant2db36262017-05-15 02:47:5314 behaviors: [
15 bookmarks.StoreClient,
16 ],
17
18 properties: {
19 /** @private {!Array<Command>} */
20 menuCommands_: {
21 type: Array,
22 value: function() {
23 return [
24 Command.EDIT,
25 Command.COPY,
26 Command.DELETE,
27 // <hr>
28 Command.OPEN_NEW_TAB,
29 Command.OPEN_NEW_WINDOW,
30 Command.OPEN_INCOGNITO,
31 ];
32 },
tsergeant13a466462017-05-15 01:21:0333 },
tsergeant2db36262017-05-15 02:47:5334
tsergeant2437f992017-06-13 23:54:2935 /** @private {Set<string>} */
tsergeant2db36262017-05-15 02:47:5336 menuIds_: Object,
tsergeant2437f992017-06-13 23:54:2937
38 /** @private */
39 globalCanEdit_: Boolean,
tsergeant13a466462017-05-15 01:21:0340 },
41
tsergeant2db36262017-05-15 02:47:5342 attached: function() {
43 assert(CommandManager.instance_ == null);
44 CommandManager.instance_ = this;
tsergeant77365182017-05-05 04:02:3345
tsergeant2437f992017-06-13 23:54:2946 this.watch('globalCanEdit_', function(state) {
47 return state.prefs.canEdit;
48 });
49 this.updateFromStore();
50
tsergeant2db36262017-05-15 02:47:5351 /** @private {function(!Event)} */
52 this.boundOnOpenItemMenu_ = this.onOpenItemMenu_.bind(this);
53 document.addEventListener('open-item-menu', this.boundOnOpenItemMenu_);
tsergeantfad224f2017-05-05 04:42:0954
calamityefe477352017-06-07 06:44:5855 /** @private {function()} */
56 this.boundOnCommandUndo_ = function() {
57 this.handle(Command.UNDO, new Set());
58 }.bind(this);
59 document.addEventListener('command-undo', this.boundOnCommandUndo_);
60
tsergeant2db36262017-05-15 02:47:5361 /** @private {function(!Event)} */
62 this.boundOnKeydown_ = this.onKeydown_.bind(this);
63 document.addEventListener('keydown', this.boundOnKeydown_);
tsergeantfad224f2017-05-05 04:42:0964
tsergeant2db36262017-05-15 02:47:5365 /** @private {Object<Command, string>} */
66 this.shortcuts_ = {};
67 this.shortcuts_[Command.EDIT] = cr.isMac ? 'enter' : 'f2';
68 this.shortcuts_[Command.COPY] = cr.isMac ? 'meta+c' : 'ctrl+c';
69 this.shortcuts_[Command.DELETE] =
70 cr.isMac ? 'delete backspace' : 'delete';
71 this.shortcuts_[Command.OPEN_NEW_TAB] =
72 cr.isMac ? 'meta+enter' : 'ctrl+enter';
73 this.shortcuts_[Command.OPEN_NEW_WINDOW] = 'shift+enter';
tsergeant6c5ad90a2017-05-19 14:12:3474 this.shortcuts_[Command.OPEN] = cr.isMac ? 'meta+down' : 'enter';
calamity2d4b5502017-05-29 03:57:5875 this.shortcuts_[Command.UNDO] = cr.isMac ? 'meta+z' : 'ctrl+z';
76 this.shortcuts_[Command.REDO] =
77 cr.isMac ? 'meta+shift+z' : 'ctrl+y ctrl+shift+z';
tsergeant2db36262017-05-15 02:47:5378 },
tsergeant77365182017-05-05 04:02:3379
tsergeant2db36262017-05-15 02:47:5380 detached: function() {
81 CommandManager.instance_ = null;
82 document.removeEventListener('open-item-menu', this.boundOnOpenItemMenu_);
calamityefe477352017-06-07 06:44:5883 document.removeEventListener('command-undo', this.boundOnCommandUndo_);
tsergeant2db36262017-05-15 02:47:5384 document.removeEventListener('keydown', this.boundOnKeydown_);
85 },
tsergeant77365182017-05-05 04:02:3386
tsergeant2db36262017-05-15 02:47:5387 /**
88 * Display the command context menu at (|x|, |y|) in window co-ordinates.
89 * Commands will execute on the currently selected items.
90 * @param {number} x
91 * @param {number} y
92 */
93 openCommandMenuAtPosition: function(x, y) {
94 this.menuIds_ = this.getState().selection.items;
95 /** @type {!CrActionMenuElement} */ (this.$.dropdown)
96 .showAtPosition({top: y, left: x});
97 },
tsergeant77365182017-05-05 04:02:3398
tsergeant2db36262017-05-15 02:47:5399 /**
100 * Display the command context menu positioned to cover the |target|
101 * element. Commands will execute on the currently selected items.
102 * @param {!Element} target
103 */
104 openCommandMenuAtElement: function(target) {
105 this.menuIds_ = this.getState().selection.items;
106 /** @type {!CrActionMenuElement} */ (this.$.dropdown).showAt(target);
107 },
tsergeant77365182017-05-05 04:02:33108
tsergeant2db36262017-05-15 02:47:53109 closeCommandMenu: function() {
tsergeant4707d172017-06-05 05:47:02110 this.menuIds_ = new Set();
tsergeant2db36262017-05-15 02:47:53111 /** @type {!CrActionMenuElement} */ (this.$.dropdown).close();
112 },
tsergeant77365182017-05-05 04:02:33113
tsergeant2db36262017-05-15 02:47:53114 ////////////////////////////////////////////////////////////////////////////
115 // Command handlers:
tsergeant77365182017-05-05 04:02:33116
tsergeant2db36262017-05-15 02:47:53117 /**
118 * Determine if the |command| can be executed with the given |itemIds|.
119 * Commands which appear in the context menu should be implemented
120 * separately using `isCommandVisible_` and `isCommandEnabled_`.
121 * @param {Command} command
122 * @param {!Set<string>} itemIds
123 * @return {boolean}
124 */
125 canExecute: function(command, itemIds) {
tsergeant6c5ad90a2017-05-19 14:12:34126 switch (command) {
127 case Command.OPEN:
128 return itemIds.size > 0;
calamity2d4b5502017-05-29 03:57:58129 case Command.UNDO:
130 case Command.REDO:
tsergeant2437f992017-06-13 23:54:29131 return this.globalCanEdit_;
tsergeant6c5ad90a2017-05-19 14:12:34132 default:
133 return this.isCommandVisible_(command, itemIds) &&
134 this.isCommandEnabled_(command, itemIds);
135 }
tsergeant2db36262017-05-15 02:47:53136 },
tsergeant13a466462017-05-15 01:21:03137
tsergeant2db36262017-05-15 02:47:53138 /**
139 * @param {Command} command
140 * @param {!Set<string>} itemIds
141 * @return {boolean} True if the command should be visible in the context
142 * menu.
143 */
144 isCommandVisible_: function(command, itemIds) {
145 switch (command) {
146 case Command.EDIT:
tsergeant2437f992017-06-13 23:54:29147 return itemIds.size == 1 && this.globalCanEdit_;
tsergeant2db36262017-05-15 02:47:53148 case Command.COPY:
tsergeant2437f992017-06-13 23:54:29149 return this.isSingleBookmark_(itemIds);
tsergeant2db36262017-05-15 02:47:53150 case Command.DELETE:
tsergeant2437f992017-06-13 23:54:29151 return itemIds.size > 0 && this.globalCanEdit_;
tsergeant2db36262017-05-15 02:47:53152 case Command.OPEN_NEW_TAB:
153 case Command.OPEN_NEW_WINDOW:
154 case Command.OPEN_INCOGNITO:
155 return itemIds.size > 0;
156 default:
157 return false;
tsergeantf1ffc892017-05-05 07:43:43158 }
tsergeant2db36262017-05-15 02:47:53159 },
tsergeantf1ffc892017-05-05 07:43:43160
tsergeant2db36262017-05-15 02:47:53161 /**
162 * @param {Command} command
163 * @param {!Set<string>} itemIds
164 * @return {boolean} True if the command should be clickable in the context
165 * menu.
166 */
167 isCommandEnabled_: function(command, itemIds) {
168 switch (command) {
tsergeant2437f992017-06-13 23:54:29169 case Command.EDIT:
170 case Command.DELETE:
171 var state = this.getState();
172 return !this.containsMatchingNode_(itemIds, function(node) {
173 return !bookmarks.util.canEditNode(state, node.id);
174 });
tsergeant2db36262017-05-15 02:47:53175 case Command.OPEN_NEW_TAB:
176 case Command.OPEN_NEW_WINDOW:
tsergeant2db36262017-05-15 02:47:53177 return this.expandUrls_(itemIds).length > 0;
tsergeant4707d172017-06-05 05:47:02178 case Command.OPEN_INCOGNITO:
179 return this.expandUrls_(itemIds).length > 0 &&
180 this.getState().prefs.incognitoAvailability !=
181 IncognitoAvailability.DISABLED;
tsergeant2db36262017-05-15 02:47:53182 default:
183 return true;
184 }
185 },
tsergeant13a466462017-05-15 01:21:03186
tsergeant2db36262017-05-15 02:47:53187 /**
188 * @param {Command} command
189 * @param {!Set<string>} itemIds
190 */
191 handle: function(command, itemIds) {
192 switch (command) {
193 case Command.EDIT:
194 var id = Array.from(itemIds)[0];
195 /** @type {!BookmarksEditDialogElement} */ (this.$.editDialog.get())
196 .showEditDialog(this.getState().nodes[id]);
197 break;
198 case Command.COPY:
199 var idList = Array.from(itemIds);
200 chrome.bookmarkManagerPrivate.copy(idList, function() {
calamityefe477352017-06-07 06:44:58201 bookmarks.ToastManager.getInstance().show(
202 loadTimeData.getString('toastUrlCopied'), false);
tsergeant2db36262017-05-15 02:47:53203 });
204 break;
205 case Command.DELETE:
calamityefe477352017-06-07 06:44:58206 var idList = Array.from(this.minimizeDeletionSet_(itemIds));
calamitye0917642017-06-09 07:34:35207 var title = this.getState().nodes[idList[0]].title;
208 var labelPromise = cr.sendWithPromise(
209 'getPluralString', 'toastItemsDeleted', idList.length);
calamityefe477352017-06-07 06:44:58210 chrome.bookmarkManagerPrivate.removeTrees(idList, function() {
211 labelPromise.then(function(label) {
calamitye0917642017-06-09 07:34:35212 var pieces = loadTimeData.getSubstitutedStringPieces(label, title)
213 .map(function(p) {
214 // Make the bookmark name collapsible.
215 p.collapsible = !!p.arg;
216 return p;
217 });
218 bookmarks.ToastManager.getInstance().showForStringPieces(
219 pieces, true);
220 }.bind(this));
calamityefe477352017-06-07 06:44:58221 }.bind(this));
tsergeant2db36262017-05-15 02:47:53222 break;
calamity2d4b5502017-05-29 03:57:58223 case Command.UNDO:
224 chrome.bookmarkManagerPrivate.undo();
calamityefe477352017-06-07 06:44:58225 bookmarks.ToastManager.getInstance().hide();
calamity2d4b5502017-05-29 03:57:58226 break;
227 case Command.REDO:
228 chrome.bookmarkManagerPrivate.redo();
229 break;
tsergeant2db36262017-05-15 02:47:53230 case Command.OPEN_NEW_TAB:
231 case Command.OPEN_NEW_WINDOW:
232 case Command.OPEN_INCOGNITO:
233 this.openUrls_(this.expandUrls_(itemIds), command);
234 break;
tsergeant6c5ad90a2017-05-19 14:12:34235 case Command.OPEN:
236 var isFolder = itemIds.size == 1 &&
237 this.containsMatchingNode_(itemIds, function(node) {
238 return !node.url;
239 });
240 if (isFolder) {
241 var folderId = Array.from(itemIds)[0];
242 this.dispatch(bookmarks.actions.selectFolder(
243 folderId, this.getState().nodes));
244 } else {
245 this.openUrls_(this.expandUrls_(itemIds), command);
246 }
247 break;
248 default:
249 assert(false);
tsergeant2db36262017-05-15 02:47:53250 }
251 },
tsergeant13a466462017-05-15 01:21:03252
tsergeant6c3a6df2017-06-06 23:53:02253 /**
254 * @param {Event} e
255 * @param {!Set<string>} itemIds
256 * @return {boolean} True if the event was handled, triggering a keyboard
257 * shortcut.
258 */
259 handleKeyEvent: function(e, itemIds) {
260 for (var commandName in this.shortcuts_) {
261 var shortcut = this.shortcuts_[commandName];
262 if (Polymer.IronA11yKeysBehavior.keyboardEventMatchesKeys(
263 e, shortcut) &&
264 this.canExecute(commandName, itemIds)) {
265 this.handle(commandName, itemIds);
266
267 e.stopPropagation();
268 e.preventDefault();
269 return true;
270 }
271 }
272
273 return false;
274 },
275
tsergeant2db36262017-05-15 02:47:53276 ////////////////////////////////////////////////////////////////////////////
277 // Private functions:
278
279 /**
280 * Minimize the set of |itemIds| by removing any node which has an ancestor
281 * node already in the set. This ensures that instead of trying to delete
282 * both a node and its descendant, we will only try to delete the topmost
283 * node, preventing an error in the bookmarkManagerPrivate.removeTrees API
284 * call.
285 * @param {!Set<string>} itemIds
286 * @return {!Set<string>}
287 */
288 minimizeDeletionSet_: function(itemIds) {
289 var minimizedSet = new Set();
290 var nodes = this.getState().nodes;
291 itemIds.forEach(function(itemId) {
292 var currentId = itemId;
293 while (currentId != ROOT_NODE_ID) {
294 currentId = assert(nodes[currentId].parentId);
295 if (itemIds.has(currentId))
296 return;
297 }
298 minimizedSet.add(itemId);
tsergeant13a466462017-05-15 01:21:03299 });
tsergeant2db36262017-05-15 02:47:53300 return minimizedSet;
301 },
tsergeant13a466462017-05-15 01:21:03302
tsergeant2db36262017-05-15 02:47:53303 /**
304 * @param {!Array<string>} urls
305 * @param {Command} command
306 * @private
307 */
308 openUrls_: function(urls, command) {
309 assert(
tsergeant6c5ad90a2017-05-19 14:12:34310 command == Command.OPEN || command == Command.OPEN_NEW_TAB ||
tsergeant2db36262017-05-15 02:47:53311 command == Command.OPEN_NEW_WINDOW ||
312 command == Command.OPEN_INCOGNITO);
tsergeant13a466462017-05-15 01:21:03313
tsergeant2db36262017-05-15 02:47:53314 if (urls.length == 0)
tsergeantfad224f2017-05-05 04:42:09315 return;
tsergeantfad224f2017-05-05 04:42:09316
tsergeant2db36262017-05-15 02:47:53317 var incognito = command == Command.OPEN_INCOGNITO;
318 if (command == Command.OPEN_NEW_WINDOW || incognito) {
319 chrome.windows.create({url: urls, incognito: incognito});
320 } else {
tsergeant6c5ad90a2017-05-19 14:12:34321 if (command == Command.OPEN)
322 chrome.tabs.create({url: urls.shift(), active: true});
tsergeant2db36262017-05-15 02:47:53323 urls.forEach(function(url) {
324 chrome.tabs.create({url: url, active: false});
tsergeant13a466462017-05-15 01:21:03325 });
tsergeant2db36262017-05-15 02:47:53326 }
327 },
tsergeant77365182017-05-05 04:02:33328
tsergeant2db36262017-05-15 02:47:53329 /**
330 * Returns all URLs in the given set of nodes and their immediate children.
331 * Note that these will be ordered by insertion order into the |itemIds|
332 * set, and that it is possible to duplicate a URL by passing in both the
333 * parent ID and child ID.
334 * @param {!Set<string>} itemIds
335 * @return {!Array<string>}
336 * @private
337 */
338 expandUrls_: function(itemIds) {
339 var urls = [];
340 var nodes = this.getState().nodes;
tsergeant13a466462017-05-15 01:21:03341
tsergeant2db36262017-05-15 02:47:53342 itemIds.forEach(function(id) {
343 var node = nodes[id];
344 if (node.url) {
345 urls.push(node.url);
346 } else {
347 node.children.forEach(function(childId) {
348 var childNode = nodes[childId];
349 if (childNode.url)
350 urls.push(childNode.url);
351 });
352 }
353 });
tsergeant13a466462017-05-15 01:21:03354
tsergeant2db36262017-05-15 02:47:53355 return urls;
356 },
357
358 /**
359 * @param {!Set<string>} itemIds
360 * @param {function(BookmarkNode):boolean} predicate
361 * @return {boolean} True if any node in |itemIds| returns true for
362 * |predicate|.
363 */
364 containsMatchingNode_: function(itemIds, predicate) {
365 var nodes = this.getState().nodes;
366
367 return Array.from(itemIds).some(function(id) {
368 return predicate(nodes[id]);
369 });
370 },
371
372 /**
tsergeant2437f992017-06-13 23:54:29373 * @param {!Set<string>} itemIds
374 * @return {boolean} True if |itemIds| is a single bookmark (non-folder)
375 * node.
376 */
377 isSingleBookmark_: function(itemIds) {
378 return itemIds.size == 1 &&
379 this.containsMatchingNode_(itemIds, function(node) {
380 return !!node.url;
381 });
382 },
383
384 /**
tsergeant2db36262017-05-15 02:47:53385 * @param {Event} e
386 * @private
387 */
388 onOpenItemMenu_: function(e) {
389 if (e.detail.targetElement) {
390 this.openCommandMenuAtElement(e.detail.targetElement);
391 } else {
392 this.openCommandMenuAtPosition(e.detail.x, e.detail.y);
393 }
394 },
395
396 /**
397 * @param {Event} e
398 * @private
399 */
400 onCommandClick_: function(e) {
tsergeant2db36262017-05-15 02:47:53401 this.handle(e.target.getAttribute('command'), assert(this.menuIds_));
tsergeant4707d172017-06-05 05:47:02402 this.closeCommandMenu();
tsergeant2db36262017-05-15 02:47:53403 },
404
405 /**
406 * @param {!Event} e
407 * @private
408 */
409 onKeydown_: function(e) {
410 var selection = this.getState().selection.items;
tsergeant6c3a6df2017-06-06 23:53:02411 if (e.target == document.body)
412 this.handleKeyEvent(e, selection);
tsergeant2db36262017-05-15 02:47:53413 },
414
415 /**
416 * Close the menu on mousedown so clicks can propagate to the underlying UI.
417 * This allows the user to right click the list while a context menu is
418 * showing and get another context menu.
419 * @param {Event} e
420 * @private
421 */
422 onMenuMousedown_: function(e) {
423 if (e.path[0] != this.$.dropdown)
424 return;
425
tsergeant4707d172017-06-05 05:47:02426 this.closeCommandMenu();
tsergeant2db36262017-05-15 02:47:53427 },
428
429 /**
430 * @param {Command} command
431 * @return {string}
432 * @private
433 */
434 getCommandLabel_: function(command) {
435 var multipleNodes = this.menuIds_.size > 1 ||
436 this.containsMatchingNode_(this.menuIds_, function(node) {
437 return !node.url;
438 });
439 var label;
440 switch (command) {
441 case Command.EDIT:
tsergeant4707d172017-06-05 05:47:02442 if (this.menuIds_.size != 1)
tsergeant2db36262017-05-15 02:47:53443 return '';
444
445 var id = Array.from(this.menuIds_)[0];
446 var itemUrl = this.getState().nodes[id].url;
447 label = itemUrl ? 'menuEdit' : 'menuRename';
448 break;
449 case Command.COPY:
450 label = 'menuCopyURL';
451 break;
452 case Command.DELETE:
453 label = 'menuDelete';
454 break;
455 case Command.OPEN_NEW_TAB:
456 label = multipleNodes ? 'menuOpenAllNewTab' : 'menuOpenNewTab';
457 break;
458 case Command.OPEN_NEW_WINDOW:
459 label = multipleNodes ? 'menuOpenAllNewWindow' : 'menuOpenNewWindow';
460 break;
461 case Command.OPEN_INCOGNITO:
462 label = multipleNodes ? 'menuOpenAllIncognito' : 'menuOpenIncognito';
463 break;
464 }
465
466 return loadTimeData.getString(assert(label));
467 },
468
469 /**
470 * @param {Command} command
471 * @return {boolean}
472 * @private
473 */
tsergeant2437f992017-06-13 23:54:29474 showDividerAfter_: function(command, itemIds) {
475 return command == Command.DELETE &&
476 (this.globalCanEdit_ || this.isSingleBookmark_(itemIds));
tsergeant2db36262017-05-15 02:47:53477 },
478 });
479
480 /** @private {bookmarks.CommandManager} */
481 CommandManager.instance_ = null;
482
483 /** @return {!bookmarks.CommandManager} */
484 CommandManager.getInstance = function() {
485 return assert(CommandManager.instance_);
486 };
487
488 return {
489 CommandManager: CommandManager,
490 };
tsergeant77365182017-05-05 04:02:33491});