Home | History | Annotate | Download | only in bmm
      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 
      8 cr.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