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