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