Merge Chromium + Blink git repositories
[chromium-blink-merge.git] / chrome / browser / resources / bookmark_manager / js / bmm / bookmark_list.js
blob718762ef417fbb032de74bdd93d806e9d1c7be0a
1 // Copyright (c) 2012 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.
5 // TODO(arv): Now that this is driven by a data model, implement a data model
6 //            that handles the loading and the events from the bookmark backend.
8 /**
9  * @typedef {{childIds: Array<string>}}
10  *
11  * @see chrome/common/extensions/api/bookmarks.json
12  */
13 var ReorderInfo;
15 /**
16  * @typedef {{parentId: string,
17  *            index: number,
18  *            oldParentId: string,
19  *            oldIndex: number}}
20  *
21  * @see chrome/common/extensions/api/bookmarks.json
22  */
23 var MoveInfo;
25 cr.define('bmm', function() {
26   'use strict';
28   var List = cr.ui.List;
29   var ListItem = cr.ui.ListItem;
30   var ArrayDataModel = cr.ui.ArrayDataModel;
31   var ContextMenuButton = cr.ui.ContextMenuButton;
33   /**
34    * Basic array data model for use with bookmarks.
35    * @param {!Array<!BookmarkTreeNode>} items The bookmark items.
36    * @constructor
37    * @extends {ArrayDataModel}
38    */
39   function BookmarksArrayDataModel(items) {
40     ArrayDataModel.call(this, items);
41   }
43   BookmarksArrayDataModel.prototype = {
44     __proto__: ArrayDataModel.prototype,
46     /**
47      * Finds the index of the bookmark with the given ID.
48      * @param {string} id The ID of the bookmark node to find.
49      * @return {number} The index of the found node or -1 if not found.
50      */
51     findIndexById: function(id) {
52       for (var i = 0; i < this.length; i++) {
53         if (this.item(i).id == id)
54           return i;
55       }
56       return -1;
57     }
58   };
60   /**
61    * Removes all children and appends a new child.
62    * @param {!Node} parent The node to remove all children from.
63    * @param {!Node} newChild The new child to append.
64    */
65   function replaceAllChildren(parent, newChild) {
66     var n;
67     while ((n = parent.lastChild)) {
68       parent.removeChild(n);
69     }
70     parent.appendChild(newChild);
71   }
73   /**
74    * Creates a new bookmark list.
75    * @param {Object=} opt_propertyBag Optional properties.
76    * @constructor
77    * @extends {cr.ui.List}
78    */
79   var BookmarkList = cr.ui.define('list');
81   BookmarkList.prototype = {
82     __proto__: List.prototype,
84     /** @override */
85     decorate: function() {
86       List.prototype.decorate.call(this);
87       this.addEventListener('mousedown', this.handleMouseDown_);
89       // HACK(arv): http://crbug.com/40902
90       window.addEventListener('resize', this.redraw.bind(this));
92       // We could add the ContextMenuButton in the BookmarkListItem but it slows
93       // down redraws a lot so we do this on mouseovers instead.
94       this.addEventListener('mouseover', this.handleMouseOver_.bind(this));
96       bmm.list = this;
97     },
99     /**
100      * @param {!BookmarkTreeNode} bookmarkNode
101      * @override
102      */
103     createItem: function(bookmarkNode) {
104       return new BookmarkListItem(bookmarkNode);
105     },
107     /** @private {string} */
108     parentId_: '',
110     /** @private {number} */
111     loadCount_: 0,
113     /**
114      * Reloads the list from the bookmarks backend.
115      */
116     reload: function() {
117       var parentId = this.parentId;
119       var callback = this.handleBookmarkCallback_.bind(this);
121       this.loadCount_++;
123       if (!parentId)
124         callback([]);
125       else if (/^q=/.test(parentId))
126         chrome.bookmarks.search(parentId.slice(2), callback);
127       else
128         chrome.bookmarks.getChildren(parentId, callback);
129     },
131     /**
132      * Callback function for loading items.
133      * @param {Array<!BookmarkTreeNode>} items The loaded items.
134      * @private
135      */
136     handleBookmarkCallback_: function(items) {
137       this.loadCount_--;
138       if (this.loadCount_)
139         return;
141       if (!items) {
142         // Failed to load bookmarks. Most likely due to the bookmark being
143         // removed.
144         cr.dispatchSimpleEvent(this, 'invalidId');
145         return;
146       }
148       this.dataModel = new BookmarksArrayDataModel(items);
150       this.fixWidth_();
151       cr.dispatchSimpleEvent(this, 'load');
152     },
154     /**
155      * The bookmark node that the list is currently displaying. If we are
156      * currently displaying search this returns null.
157      * @type {BookmarkTreeNode}
158      */
159     get bookmarkNode() {
160       if (this.isSearch())
161         return null;
162       var treeItem = bmm.treeLookup[this.parentId];
163       return treeItem && treeItem.bookmarkNode;
164     },
166     /**
167      * @return {boolean} Whether we are currently showing search results.
168      */
169     isSearch: function() {
170       return this.parentId_[0] == 'q';
171     },
173     /**
174      * @return {boolean} Whether we are editing an ephemeral item.
175      */
176     hasEphemeral: function() {
177       var dataModel = this.dataModel;
178       for (var i = 0; i < dataModel.array_.length; i++) {
179         if (dataModel.array_[i].id == 'new')
180           return true;
181       }
182       return false;
183     },
185     /**
186      * Handles mouseover on the list so that we can add the context menu button
187      * lazily.
188      * @private
189      * @param {!Event} e The mouseover event object.
190      */
191     handleMouseOver_: function(e) {
192       var el = e.target;
193       while (el && el.parentNode != this) {
194         el = el.parentNode;
195       }
197       if (el && el.parentNode == this &&
198           !el.editing &&
199           !(el.lastChild instanceof ContextMenuButton)) {
200         el.appendChild(new ContextMenuButton);
201       }
202     },
204     /**
205      * Dispatches an urlClicked event which is used to open URLs in new
206      * tabs etc.
207      * @private
208      * @param {string} url The URL that was clicked.
209      * @param {!Event} originalEvent The original click event object.
210      */
211     dispatchUrlClickedEvent_: function(url, originalEvent) {
212       var event = new Event('urlClicked', {bubbles: true});
213       event.url = url;
214       event.originalEvent = originalEvent;
215       this.dispatchEvent(event);
216     },
218     /**
219      * Handles mousedown events so that we can prevent the auto scroll as
220      * necessary.
221      * @private
222      * @param {!Event} e The mousedown event object.
223      */
224     handleMouseDown_: function(e) {
225       e = /** @type {!MouseEvent} */(e);
226       if (e.button == 1) {
227         // WebKit no longer fires click events for middle clicks so we manually
228         // listen to mouse up to dispatch a click event.
229         this.addEventListener('mouseup', this.handleMiddleMouseUp_);
231         // When the user does a middle click we need to prevent the auto scroll
232         // in case the user is trying to middle click to open a bookmark in a
233         // background tab.
234         // We do not do this in case the target is an input since middle click
235         // is also paste on Linux and we don't want to break that.
236         if (e.target.tagName != 'INPUT')
237           e.preventDefault();
238       }
239     },
241     /**
242      * WebKit no longer dispatches click events for middle clicks so we need
243      * to emulate it.
244      * @private
245      * @param {!Event} e The mouse up event object.
246      */
247     handleMiddleMouseUp_: function(e) {
248       e = /** @type {!MouseEvent} */(e);
249       this.removeEventListener('mouseup', this.handleMiddleMouseUp_);
250       if (e.button == 1) {
251         var el = e.target;
252         while (el.parentNode != this) {
253           el = el.parentNode;
254         }
255         var node = el.bookmarkNode;
256         if (node && !bmm.isFolder(node))
257           this.dispatchUrlClickedEvent_(node.url, e);
258       }
259       e.preventDefault();
260     },
262     // Bookmark model update callbacks
263     handleBookmarkChanged: function(id, changeInfo) {
264       var dataModel = this.dataModel;
265       var index = dataModel.findIndexById(id);
266       if (index != -1) {
267         var bookmarkNode = this.dataModel.item(index);
268         bookmarkNode.title = changeInfo.title;
269         if ('url' in changeInfo)
270           bookmarkNode.url = changeInfo['url'];
272         dataModel.updateIndex(index);
273       }
274     },
276     /**
277      * @param {string} id
278      * @param {ReorderInfo} reorderInfo
279      */
280     handleChildrenReordered: function(id, reorderInfo) {
281       if (this.parentId == id) {
282         // We create a new data model with updated items in the right order.
283         var dataModel = this.dataModel;
284         var items = {};
285         for (var i = this.dataModel.length - 1; i >= 0; i--) {
286           var bookmarkNode = dataModel.item(i);
287           items[bookmarkNode.id] = bookmarkNode;
288         }
289         var newArray = [];
290         for (var i = 0; i < reorderInfo.childIds.length; i++) {
291           newArray[i] = items[reorderInfo.childIds[i]];
292           newArray[i].index = i;
293         }
295         this.dataModel = new BookmarksArrayDataModel(newArray);
296       }
297     },
299     handleCreated: function(id, bookmarkNode) {
300       if (this.parentId == bookmarkNode.parentId)
301         this.dataModel.splice(bookmarkNode.index, 0, bookmarkNode);
302     },
304     /**
305      * @param {string} id
306      * @param {MoveInfo} moveInfo
307      */
308     handleMoved: function(id, moveInfo) {
309       if (moveInfo.parentId == this.parentId ||
310           moveInfo.oldParentId == this.parentId) {
312         var dataModel = this.dataModel;
314         if (moveInfo.oldParentId == moveInfo.parentId) {
315           // Reorder within this folder
317           this.startBatchUpdates();
319           var bookmarkNode = this.dataModel.item(moveInfo.oldIndex);
320           this.dataModel.splice(moveInfo.oldIndex, 1);
321           this.dataModel.splice(moveInfo.index, 0, bookmarkNode);
323           this.endBatchUpdates();
324         } else {
325           if (moveInfo.oldParentId == this.parentId) {
326             // Move out of this folder
328             var index = dataModel.findIndexById(id);
329             if (index != -1)
330               dataModel.splice(index, 1);
331           }
333           if (moveInfo.parentId == this.parentId) {
334             // Move to this folder
335             var self = this;
336             chrome.bookmarks.get(id, function(bookmarkNodes) {
337               var bookmarkNode = bookmarkNodes[0];
338               dataModel.splice(bookmarkNode.index, 0, bookmarkNode);
339             });
340           }
341         }
342       }
343     },
345     handleRemoved: function(id, removeInfo) {
346       var dataModel = this.dataModel;
347       var index = dataModel.findIndexById(id);
348       if (index != -1)
349         dataModel.splice(index, 1);
350     },
352     /**
353      * Workaround for http://crbug.com/40902
354      * @private
355      */
356     fixWidth_: function() {
357       var list = bmm.list;
358       if (this.loadCount_ || !list)
359         return;
361       // The width of the list is wrong after its content has changed.
362       // Fortunately the reported offsetWidth is correct so we can detect the
363       //incorrect width.
364       if (list.offsetWidth != list.parentNode.clientWidth - list.offsetLeft) {
365         // Set the width to the correct size. This causes the relayout.
366         list.style.width = list.parentNode.clientWidth - list.offsetLeft + 'px';
367         // Remove the temporary style.width in a timeout. Once the timer fires
368         // the size should not change since we already fixed the width.
369         window.setTimeout(function() {
370           list.style.width = '';
371         }, 0);
372       }
373     }
374   };
376   /**
377    * The ID of the bookmark folder we are displaying.
378    */
379   cr.defineProperty(BookmarkList, 'parentId', cr.PropertyKind.JS,
380                     function() {
381                       this.reload();
382                     });
384   /**
385    * The contextMenu property.
386    */
387   cr.ui.contextMenuHandler.addContextMenuProperty(BookmarkList);
388   /** @type {cr.ui.Menu} */
389   BookmarkList.prototype.contextMenu;
391   /**
392    * Creates a new bookmark list item.
393    * @param {!BookmarkTreeNode} bookmarkNode The bookmark node this represents.
394    * @constructor
395    * @extends {cr.ui.ListItem}
396    */
397   function BookmarkListItem(bookmarkNode) {
398     var el = cr.doc.createElement('div');
399     el.bookmarkNode = bookmarkNode;
400     BookmarkListItem.decorate(el);
401     return el;
402   }
404   /**
405    * Decorates an element as a bookmark list item.
406    * @param {!HTMLElement} el The element to decorate.
407    */
408   BookmarkListItem.decorate = function(el) {
409     el.__proto__ = BookmarkListItem.prototype;
410     el.decorate();
411   };
413   BookmarkListItem.prototype = {
414     __proto__: ListItem.prototype,
416     /** @override */
417     decorate: function() {
418       ListItem.prototype.decorate.call(this);
420       var bookmarkNode = this.bookmarkNode;
422       this.draggable = true;
424       var labelEl = this.ownerDocument.createElement('div');
425       labelEl.className = 'label';
426       labelEl.textContent = bookmarkNode.title;
428       var urlEl = this.ownerDocument.createElement('div');
429       urlEl.className = 'url';
431       if (bmm.isFolder(bookmarkNode)) {
432         this.className = 'folder';
433       } else {
434         labelEl.style.backgroundImage = getFaviconImageSet(bookmarkNode.url);
435         labelEl.style.backgroundSize = '16px';
436         urlEl.textContent = bookmarkNode.url;
437       }
439       this.appendChild(labelEl);
440       this.appendChild(urlEl);
442       // Initially the ContextMenuButton was added here but it slowed down
443       // rendering a lot so it is now added using mouseover.
444     },
446     /**
447      * The ID of the bookmark folder we are currently showing or loading.
448      * @type {string}
449      */
450     get bookmarkId() {
451       return this.bookmarkNode.id;
452     },
454     /**
455      * Whether the user is currently able to edit the list item.
456      * @type {boolean}
457      */
458     get editing() {
459       return this.hasAttribute('editing');
460     },
461     set editing(editing) {
462       var oldEditing = this.editing;
463       if (oldEditing == editing)
464         return;
466       var url = this.bookmarkNode.url;
467       var title = this.bookmarkNode.title;
468       var isFolder = bmm.isFolder(this.bookmarkNode);
469       var listItem = this;
470       var labelEl = this.firstChild;
471       var urlEl = labelEl.nextSibling;
472       var labelInput, urlInput;
474       // Handles enter and escape which trigger reset and commit respectively.
475       function handleKeydown(e) {
476         // Make sure that the tree does not handle the key.
477         e.stopPropagation();
479         // Calling list.focus blurs the input which will stop editing the list
480         // item.
481         switch (e.keyIdentifier) {
482           case 'U+001B':  // Esc
483             labelInput.value = title;
484             if (!isFolder)
485               urlInput.value = url;
486             // fall through
487             cr.dispatchSimpleEvent(listItem, 'canceledit', true);
488           case 'Enter':
489             if (listItem.parentNode)
490               listItem.parentNode.focus();
491             break;
492           case 'U+0009':  // Tab
493             // urlInput is the last focusable element in the page.  If we
494             // allowed Tab focus navigation and the page loses focus, we
495             // couldn't give focus on urlInput programatically. So, we prevent
496             // Tab focus navigation.
497             if (document.activeElement == urlInput && !e.ctrlKey &&
498                 !e.metaKey && !e.shiftKey && !getValidURL(urlInput)) {
499               e.preventDefault();
500               urlInput.blur();
501             }
502             break;
503         }
504       }
506       function getValidURL(input) {
507         var originalValue = input.value;
508         if (!originalValue)
509           return null;
510         if (input.validity.valid)
511           return originalValue;
512         // Blink does not do URL fix up so we manually test if prepending
513         // 'http://' would make the URL valid.
514         // https://bugs.webkit.org/show_bug.cgi?id=29235
515         input.value = 'http://' + originalValue;
516         if (input.validity.valid)
517           return input.value;
518         // still invalid
519         input.value = originalValue;
520         return null;
521       }
523       function handleBlur(e) {
524         // When the blur event happens we do not know who is getting focus so we
525         // delay this a bit since we want to know if the other input got focus
526         // before deciding if we should exit edit mode.
527         var doc = e.target.ownerDocument;
528         window.setTimeout(function() {
529           var activeElement = doc.hasFocus() && doc.activeElement;
530           if (activeElement != urlInput && activeElement != labelInput) {
531             listItem.editing = false;
532           }
533         }, 50);
534       }
536       var doc = this.ownerDocument;
537       if (editing) {
538         this.setAttribute('editing', '');
539         this.draggable = false;
541         labelInput = /** @type {HTMLElement} */(doc.createElement('input'));
542         labelInput.placeholder =
543             loadTimeData.getString('name_input_placeholder');
544         replaceAllChildren(labelEl, labelInput);
545         labelInput.value = title;
547         if (!isFolder) {
548           urlInput = /** @type {HTMLElement} */(doc.createElement('input'));
549           urlInput.type = 'url';
550           urlInput.required = true;
551           urlInput.placeholder =
552               loadTimeData.getString('url_input_placeholder');
554           // We also need a name for the input for the CSS to work.
555           urlInput.name = '-url-input-' + cr.createUid();
556           replaceAllChildren(assert(urlEl), urlInput);
557           urlInput.value = url;
558         }
560         var stopPropagation = function(e) {
561           e.stopPropagation();
562         };
564         var eventsToStop =
565             ['mousedown', 'mouseup', 'contextmenu', 'dblclick', 'paste'];
566         eventsToStop.forEach(function(type) {
567           labelInput.addEventListener(type, stopPropagation);
568         });
569         labelInput.addEventListener('keydown', handleKeydown);
570         labelInput.addEventListener('blur', handleBlur);
571         cr.ui.limitInputWidth(labelInput, this, 100, 0.5);
572         labelInput.focus();
573         labelInput.select();
575         if (!isFolder) {
576           eventsToStop.forEach(function(type) {
577             urlInput.addEventListener(type, stopPropagation);
578           });
579           urlInput.addEventListener('keydown', handleKeydown);
580           urlInput.addEventListener('blur', handleBlur);
581           cr.ui.limitInputWidth(urlInput, this, 200, 0.5);
582         }
584       } else {
585         // Check that we have a valid URL and if not we do not change the
586         // editing mode.
587         if (!isFolder) {
588           var urlInput = this.querySelector('.url input');
589           var newUrl = urlInput.value;
590           if (!newUrl) {
591             cr.dispatchSimpleEvent(this, 'canceledit', true);
592             return;
593           }
595           newUrl = getValidURL(urlInput);
596           if (!newUrl) {
597             // In case the item was removed before getting here we should
598             // not alert.
599             if (listItem.parentNode) {
600               // Select the item again.
601               var dataModel = this.parentNode.dataModel;
602               var index = dataModel.indexOf(this.bookmarkNode);
603               var sm = this.parentNode.selectionModel;
604               sm.selectedIndex = sm.leadIndex = sm.anchorIndex = index;
606               alert(loadTimeData.getString('invalid_url'));
607             }
608             urlInput.focus();
609             urlInput.select();
610             return;
611           }
612           urlEl.textContent = this.bookmarkNode.url = newUrl;
613         }
615         this.removeAttribute('editing');
616         this.draggable = true;
618         labelInput = this.querySelector('.label input');
619         var newLabel = labelInput.value;
620         labelEl.textContent = this.bookmarkNode.title = newLabel;
622         if (isFolder) {
623           if (newLabel != title) {
624             cr.dispatchSimpleEvent(this, 'rename', true);
625           }
626         } else if (newLabel != title || newUrl != url) {
627           cr.dispatchSimpleEvent(this, 'edit', true);
628         }
629       }
630     }
631   };
633   return {
634     BookmarkList: BookmarkList,
635     list: /** @type {Element} */(null),  // Set when decorated.
636   };