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