Disable view source for Developer Tools.
[chromium-blink-merge.git] / chrome / browser / resources / bookmark_manager / js / bmm / bookmark_list.js
blobf417ae6a7bd3e01f8f7ce5f452be8ad912c10bb5
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 cr.define('bmm', function() {
9   var List = cr.ui.List;
10   var ListItem = cr.ui.ListItem;
11   var ArrayDataModel = cr.ui.ArrayDataModel;
12   var ContextMenuButton = cr.ui.ContextMenuButton;
14   var list;
16   /**
17    * Basic array data model for use with bookmarks.
18    * @param {!Array.<!BookmarkTreeNode>} items The bookmark items.
19    * @constructor
20    * @extends {ArrayDataModel}
21    */
22   function BookmarksArrayDataModel(items) {
23     ArrayDataModel.call(this, items);
24   }
26   BookmarksArrayDataModel.prototype = {
27     __proto__: ArrayDataModel.prototype,
29     /**
30      * Finds the index of the bookmark with the given ID.
31      * @param {string} id The ID of the bookmark node to find.
32      * @return {number} The index of the found node or -1 if not found.
33      */
34     findIndexById: function(id) {
35       for (var i = 0; i < this.length; i++) {
36         if (this.item(i).id == id)
37           return i;
38       }
39       return -1;
40     }
41   };
43   /**
44    * Removes all children and appends a new child.
45    * @param {!Node} parent The node to remove all children from.
46    * @param {!Node} newChild The new child to append.
47    */
48   function replaceAllChildren(parent, newChild) {
49     var n;
50     while ((n = parent.lastChild)) {
51       parent.removeChild(n);
52     }
53     parent.appendChild(newChild);
54   }
56   /**
57    * Creates a new bookmark list.
58    * @param {Object=} opt_propertyBag Optional properties.
59    * @constructor
60    * @extends {HTMLButtonElement}
61    */
62   var BookmarkList = cr.ui.define('list');
64   BookmarkList.prototype = {
65     __proto__: List.prototype,
67     /** @override */
68     decorate: function() {
69       List.prototype.decorate.call(this);
70       this.addEventListener('mousedown', this.handleMouseDown_);
72       // HACK(arv): http://crbug.com/40902
73       window.addEventListener('resize', this.redraw.bind(this));
75       // We could add the ContextMenuButton in the BookmarkListItem but it slows
76       // down redraws a lot so we do this on mouseovers instead.
77       this.addEventListener('mouseover', this.handleMouseOver_.bind(this));
79       bmm.list = this;
80     },
82     createItem: function(bookmarkNode) {
83       return new BookmarkListItem(bookmarkNode);
84     },
86     parentId_: '',
88     /**
89      * Reloads the list from the bookmarks backend.
90      */
91     reload: function() {
92       var parentId = this.parentId;
94       var callback = this.handleBookmarkCallback_.bind(this);
95       this.loading_ = true;
97       if (!parentId)
98         callback([]);
99       else if (/^q=/.test(parentId))
100         chrome.bookmarks.search(parentId.slice(2), callback);
101       else
102         chrome.bookmarks.getChildren(parentId, callback);
103     },
105     /**
106      * Callback function for loading items.
107      * @param {Array.<!BookmarkTreeNode>} items The loaded items.
108      * @private
109      */
110     handleBookmarkCallback_: function(items) {
111       if (!items) {
112         // Failed to load bookmarks. Most likely due to the bookmark being
113         // removed.
114         cr.dispatchSimpleEvent(this, 'invalidId');
115         this.loading_ = false;
116         return;
117       }
119       this.dataModel = new BookmarksArrayDataModel(items);
121       this.loading_ = false;
122       this.fixWidth_();
123       cr.dispatchSimpleEvent(this, 'load');
124     },
126     /**
127      * The bookmark node that the list is currently displaying. If we are
128      * currently displaying search this returns null.
129      * @type {BookmarkTreeNode}
130      */
131     get bookmarkNode() {
132       if (this.isSearch())
133         return null;
134       var treeItem = bmm.treeLookup[this.parentId];
135       return treeItem && treeItem.bookmarkNode;
136     },
138     /**
139      * @return {boolean} Whether we are currently showing search results.
140      */
141     isSearch: function() {
142       return this.parentId_[0] == 'q';
143     },
145     /**
146      * @return {boolean} Whether we are editing an ephemeral item.
147      */
148     hasEphemeral: function() {
149       var dataModel = this.dataModel;
150       for (var i = 0; i < dataModel.array_.length; i++) {
151         if (dataModel.array_[i].id == 'new')
152           return true;
153       }
154       return false;
155     },
157     /**
158      * Handles mouseover on the list so that we can add the context menu button
159      * lazily.
160      * @private
161      * @param {!Event} e The mouseover event object.
162      */
163     handleMouseOver_: function(e) {
164       var el = e.target;
165       while (el && el.parentNode != this) {
166         el = el.parentNode;
167       }
169       if (el && el.parentNode == this &&
170           !el.editing &&
171           !(el.lastChild instanceof ContextMenuButton)) {
172         el.appendChild(new ContextMenuButton);
173       }
174     },
176     /**
177      * Dispatches an urlClicked event which is used to open URLs in new
178      * tabs etc.
179      * @private
180      * @param {string} url The URL that was clicked.
181      * @param {!Event} originalEvent The original click event object.
182      */
183     dispatchUrlClickedEvent_: function(url, originalEvent) {
184       var event = new Event('urlClicked', {bubbles: true});
185       event.url = url;
186       event.originalEvent = originalEvent;
187       this.dispatchEvent(event);
188     },
190     /**
191      * Handles mousedown events so that we can prevent the auto scroll as
192      * necessary.
193      * @private
194      * @param {!MouseEvent} e The mousedown event object.
195      */
196     handleMouseDown_: function(e) {
197       if (e.button == 1) {
198         // WebKit no longer fires click events for middle clicks so we manually
199         // listen to mouse up to dispatch a click event.
200         this.addEventListener('mouseup', this.handleMiddleMouseUp_);
202         // When the user does a middle click we need to prevent the auto scroll
203         // in case the user is trying to middle click to open a bookmark in a
204         // background tab.
205         // We do not do this in case the target is an input since middle click
206         // is also paste on Linux and we don't want to break that.
207         if (e.target.tagName != 'INPUT')
208           e.preventDefault();
209       }
210     },
212     /**
213      * WebKit no longer dispatches click events for middle clicks so we need
214      * to emulate it.
215      * @private
216      * @param {!MouseEvent} e The mouse up event object.
217      */
218     handleMiddleMouseUp_: function(e) {
219       this.removeEventListener('mouseup', this.handleMiddleMouseUp_);
220       if (e.button == 1) {
221         var el = e.target;
222         while (el.parentNode != this) {
223           el = el.parentNode;
224         }
225         var node = el.bookmarkNode;
226         if (node && !bmm.isFolder(node))
227           this.dispatchUrlClickedEvent_(node.url, e);
228       }
229     },
231     // Bookmark model update callbacks
232     handleBookmarkChanged: function(id, changeInfo) {
233       var dataModel = this.dataModel;
234       var index = dataModel.findIndexById(id);
235       if (index != -1) {
236         var bookmarkNode = this.dataModel.item(index);
237         bookmarkNode.title = changeInfo.title;
238         if ('url' in changeInfo)
239           bookmarkNode.url = changeInfo['url'];
241         dataModel.updateIndex(index);
242       }
243     },
245     handleChildrenReordered: function(id, reorderInfo) {
246       if (this.parentId == id) {
247         // We create a new data model with updated items in the right order.
248         var dataModel = this.dataModel;
249         var items = {};
250         for (var i = this.dataModel.length - 1; i >= 0; i--) {
251           var bookmarkNode = dataModel.item(i);
252           items[bookmarkNode.id] = bookmarkNode;
253         }
254         var newArray = [];
255         for (var i = 0; i < reorderInfo.childIds.length; i++) {
256           newArray[i] = items[reorderInfo.childIds[i]];
257           newArray[i].index = i;
258         }
260         this.dataModel = new BookmarksArrayDataModel(newArray);
261       }
262     },
264     handleCreated: function(id, bookmarkNode) {
265       if (this.parentId == bookmarkNode.parentId)
266         this.dataModel.splice(bookmarkNode.index, 0, bookmarkNode);
267     },
269     handleMoved: function(id, moveInfo) {
270       if (moveInfo.parentId == this.parentId ||
271           moveInfo.oldParentId == this.parentId) {
273         var dataModel = this.dataModel;
275         if (moveInfo.oldParentId == moveInfo.parentId) {
276           // Reorder within this folder
278           this.startBatchUpdates();
280           var bookmarkNode = this.dataModel.item(moveInfo.oldIndex);
281           this.dataModel.splice(moveInfo.oldIndex, 1);
282           this.dataModel.splice(moveInfo.index, 0, bookmarkNode);
284           this.endBatchUpdates();
285         } else {
286           if (moveInfo.oldParentId == this.parentId) {
287             // Move out of this folder
289             var index = dataModel.findIndexById(id);
290             if (index != -1)
291               dataModel.splice(index, 1);
292           }
294           if (moveInfo.parentId == this.parentId) {
295             // Move to this folder
296             var self = this;
297             chrome.bookmarks.get(id, function(bookmarkNodes) {
298               var bookmarkNode = bookmarkNodes[0];
299               dataModel.splice(bookmarkNode.index, 0, bookmarkNode);
300             });
301           }
302         }
303       }
304     },
306     handleRemoved: function(id, removeInfo) {
307       var dataModel = this.dataModel;
308       var index = dataModel.findIndexById(id);
309       if (index != -1)
310         dataModel.splice(index, 1);
311     },
313     /**
314      * Workaround for http://crbug.com/40902
315      * @private
316      */
317     fixWidth_: function() {
318       var list = bmm.list;
319       if (this.loading_ || !list)
320         return;
322       // The width of the list is wrong after its content has changed.
323       // Fortunately the reported offsetWidth is correct so we can detect the
324       //incorrect width.
325       if (list.offsetWidth != list.parentNode.clientWidth - list.offsetLeft) {
326         // Set the width to the correct size. This causes the relayout.
327         list.style.width = list.parentNode.clientWidth - list.offsetLeft + 'px';
328         // Remove the temporary style.width in a timeout. Once the timer fires
329         // the size should not change since we already fixed the width.
330         window.setTimeout(function() {
331           list.style.width = '';
332         }, 0);
333       }
334     }
335   };
337   /**
338    * The ID of the bookmark folder we are displaying.
339    * @type {string}
340    */
341   cr.defineProperty(BookmarkList, 'parentId', cr.PropertyKind.JS,
342                     function() {
343                       this.reload();
344                     });
346   /**
347    * The contextMenu property.
348    * @type {cr.ui.Menu}
349    */
350   cr.ui.contextMenuHandler.addContextMenuProperty(BookmarkList);
352   /**
353    * Creates a new bookmark list item.
354    * @param {!BookmarkTreeNode} bookmarkNode The bookmark node this represents.
355    * @constructor
356    * @extends {cr.ui.ListItem}
357    */
358   function BookmarkListItem(bookmarkNode) {
359     var el = cr.doc.createElement('div');
360     el.bookmarkNode = bookmarkNode;
361     BookmarkListItem.decorate(el);
362     return el;
363   }
365   /**
366    * Decorates an element as a bookmark list item.
367    * @param {!HTMLElement} el The element to decorate.
368    */
369   BookmarkListItem.decorate = function(el) {
370     el.__proto__ = BookmarkListItem.prototype;
371     el.decorate();
372   };
374   BookmarkListItem.prototype = {
375     __proto__: ListItem.prototype,
377     /** @override */
378     decorate: function() {
379       ListItem.prototype.decorate.call(this);
381       var bookmarkNode = this.bookmarkNode;
383       this.draggable = true;
385       var labelEl = this.ownerDocument.createElement('div');
386       labelEl.className = 'label';
387       labelEl.textContent = bookmarkNode.title;
389       var urlEl = this.ownerDocument.createElement('div');
390       urlEl.className = 'url';
392       if (bmm.isFolder(bookmarkNode)) {
393         this.className = 'folder';
394       } else {
395         labelEl.style.backgroundImage = getFaviconImageSet(bookmarkNode.url);
396         labelEl.style.backgroundSize = '16px';
397         urlEl.textContent = bookmarkNode.url;
398       }
400       this.appendChild(labelEl);
401       this.appendChild(urlEl);
403       // Initially the ContextMenuButton was added here but it slowed down
404       // rendering a lot so it is now added using mouseover.
405     },
407     /**
408      * The ID of the bookmark folder we are currently showing or loading.
409      * @type {string}
410      */
411     get bookmarkId() {
412       return this.bookmarkNode.id;
413     },
415     /**
416      * Whether the user is currently able to edit the list item.
417      * @type {boolean}
418      */
419     get editing() {
420       return this.hasAttribute('editing');
421     },
422     set editing(editing) {
423       var oldEditing = this.editing;
424       if (oldEditing == editing)
425         return;
427       var url = this.bookmarkNode.url;
428       var title = this.bookmarkNode.title;
429       var isFolder = bmm.isFolder(this.bookmarkNode);
430       var listItem = this;
431       var labelEl = this.firstChild;
432       var urlEl = labelEl.nextSibling;
433       var labelInput, urlInput;
435       // Handles enter and escape which trigger reset and commit respectively.
436       function handleKeydown(e) {
437         // Make sure that the tree does not handle the key.
438         e.stopPropagation();
440         // Calling list.focus blurs the input which will stop editing the list
441         // item.
442         switch (e.keyIdentifier) {
443           case 'U+001B':  // Esc
444             labelInput.value = title;
445             if (!isFolder)
446               urlInput.value = url;
447             // fall through
448             cr.dispatchSimpleEvent(listItem, 'canceledit', true);
449           case 'Enter':
450             if (listItem.parentNode)
451               listItem.parentNode.focus();
452         }
453       }
455       function handleBlur(e) {
456         // When the blur event happens we do not know who is getting focus so we
457         // delay this a bit since we want to know if the other input got focus
458         // before deciding if we should exit edit mode.
459         var doc = e.target.ownerDocument;
460         window.setTimeout(function() {
461           var activeElement = doc.hasFocus() && doc.activeElement;
462           if (activeElement != urlInput && activeElement != labelInput) {
463             listItem.editing = false;
464           }
465         }, 50);
466       }
468       var doc = this.ownerDocument;
469       if (editing) {
470         this.setAttribute('editing', '');
471         this.draggable = false;
473         labelInput = doc.createElement('input');
474         labelInput.placeholder =
475             loadTimeData.getString('name_input_placeholder');
476         replaceAllChildren(labelEl, labelInput);
477         labelInput.value = title;
479         if (!isFolder) {
480           urlInput = doc.createElement('input');
481           urlInput.type = 'url';
482           urlInput.required = true;
483           urlInput.placeholder =
484               loadTimeData.getString('url_input_placeholder');
486           // We also need a name for the input for the CSS to work.
487           urlInput.name = '-url-input-' + cr.createUid();
488           replaceAllChildren(urlEl, urlInput);
489           urlInput.value = url;
490         }
492         function stopPropagation(e) {
493           e.stopPropagation();
494         }
496         var eventsToStop =
497             ['mousedown', 'mouseup', 'contextmenu', 'dblclick', 'paste'];
498         eventsToStop.forEach(function(type) {
499           labelInput.addEventListener(type, stopPropagation);
500         });
501         labelInput.addEventListener('keydown', handleKeydown);
502         labelInput.addEventListener('blur', handleBlur);
503         cr.ui.limitInputWidth(labelInput, this, 100, 0.5);
504         labelInput.focus();
505         labelInput.select();
507         if (!isFolder) {
508           eventsToStop.forEach(function(type) {
509             urlInput.addEventListener(type, stopPropagation);
510           });
511           urlInput.addEventListener('keydown', handleKeydown);
512           urlInput.addEventListener('blur', handleBlur);
513           cr.ui.limitInputWidth(urlInput, this, 200, 0.5);
514         }
516       } else {
517         // Check that we have a valid URL and if not we do not change the
518         // editing mode.
519         if (!isFolder) {
520           var urlInput = this.querySelector('.url input');
521           var newUrl = urlInput.value;
522           if (!newUrl) {
523             cr.dispatchSimpleEvent(this, 'canceledit', true);
524             return;
525           }
527           if (!urlInput.validity.valid) {
528             // WebKit does not do URL fix up so we manually test if prepending
529             // 'http://' would make the URL valid.
530             // https://bugs.webkit.org/show_bug.cgi?id=29235
531             urlInput.value = 'http://' + newUrl;
532             if (!urlInput.validity.valid) {
533               // still invalid
534               urlInput.value = newUrl;
536               // In case the item was removed before getting here we should
537               // not alert.
538               if (listItem.parentNode) {
539                 // Select the item again.
540                 var dataModel = this.parentNode.dataModel;
541                 var index = dataModel.indexOf(this.bookmarkNode);
542                 var sm = this.parentNode.selectionModel;
543                 sm.selectedIndex = sm.leadIndex = sm.anchorIndex = index;
545                 alert(loadTimeData.getString('invalid_url'));
546               }
547               urlInput.focus();
548               urlInput.select();
549               return;
550             }
551             newUrl = 'http://' + newUrl;
552           }
553           urlEl.textContent = this.bookmarkNode.url = newUrl;
554         }
556         this.removeAttribute('editing');
557         this.draggable = true;
559         labelInput = this.querySelector('.label input');
560         var newLabel = labelInput.value;
561         labelEl.textContent = this.bookmarkNode.title = newLabel;
563         if (isFolder) {
564           if (newLabel != title) {
565             cr.dispatchSimpleEvent(this, 'rename', true);
566           }
567         } else if (newLabel != title || newUrl != url) {
568           cr.dispatchSimpleEvent(this, 'edit', true);
569         }
570       }
571     }
572   };
574   return {
575     BookmarkList: BookmarkList,
576     list: list
577   };