text: // Copyright (c) 2012 The Chromium Authors. All rights reserved.
  // Use of this source code is governed by a BSD-style license that can be
  // found in the LICENSE file.
  
  // TODO(arv): Now that this is driven by a data model, implement a data model
  //            that handles the loading and the events from the bookmark backend.
  
  /**
   * @typedef {{childIds: Array<string>}}
   *
   * @see chrome/common/extensions/api/bookmarks.json
   */
  var ReorderInfo;
  
  /**
   * @typedef {{parentId: string,
   *            index: number,
   *            oldParentId: string,
   *            oldIndex: number}}
   *
   * @see chrome/common/extensions/api/bookmarks.json
   */
  var MoveInfo;
  
  cr.define('bmm', function() {
    'use strict';
  
    var List = cr.ui.List;
    var ListItem = cr.ui.ListItem;
    var ArrayDataModel = cr.ui.ArrayDataModel;
    var ContextMenuButton = cr.ui.ContextMenuButton;
  
    /**
     * Basic array data model for use with bookmarks.
     * @param {!Array<!BookmarkTreeNode>} items The bookmark items.
     * @constructor
     * @extends {ArrayDataModel}
     */
    function BookmarksArrayDataModel(items) {
      ArrayDataModel.call(this, items);
    }
  
    BookmarksArrayDataModel.prototype = {
      __proto__: ArrayDataModel.prototype,
  
      /**
       * Finds the index of the bookmark with the given ID.
       * @param {string} id The ID of the bookmark node to find.
       * @return {number} The index of the found node or -1 if not found.
       */
      findIndexById: function(id) {
        for (var i = 0; i < this.length; i++) {
          if (this.item(i).id == id)
            return i;
        }
        return -1;
      }
    };
  
    /**
     * Removes all children and appends a new child.
     * @param {!Node} parent The node to remove all children from.
     * @param {!Node} newChild The new child to append.
     */
    function replaceAllChildren(parent, newChild) {
      var n;
      while ((n = parent.lastChild)) {
        parent.removeChild(n);
      }
      parent.appendChild(newChild);
    }
  
    /**
     * Creates a new bookmark list.
     * @param {Object=} opt_propertyBag Optional properties.
     * @constructor
     * @extends {cr.ui.List}
     */
    var BookmarkList = cr.ui.define('list');
  
    BookmarkList.prototype = {
      __proto__: List.prototype,
  
      /** @override */
      decorate: function() {
        List.prototype.decorate.call(this);
        this.addEventListener('mousedown', this.handleMouseDown_);
  
        // HACK(arv): http://crbug.com/40902
        window.addEventListener('resize', this.redraw.bind(this));
  
        // We could add the ContextMenuButton in the BookmarkListItem but it slows
        // down redraws a lot so we do this on mouseovers instead.
        this.addEventListener('mouseover', this.handleMouseOver_.bind(this));
  
        bmm.list = this;
      },
  
      /**
       * @param {!BookmarkTreeNode} bookmarkNode
       * @override
       */
      createItem: function(bookmarkNode) {
        return new BookmarkListItem(bookmarkNode);
      },
  
      /** @private {string} */
      parentId_: '',
  
      /** @private {number} */
      loadCount_: 0,
  
      /**
       * Reloads the list from the bookmarks backend.
       */
      reload: function() {
        var parentId = this.parentId;
  
        var callback = this.handleBookmarkCallback_.bind(this);
  
        this.loadCount_++;
  
        if (!parentId)
          callback([]);
        else if (/^q=/.test(parentId))
          chrome.bookmarks.search(parentId.slice(2), callback);
        else
          chrome.bookmarks.getChildren(parentId, callback);
      },
  
      /**
       * Callback function for loading items.
       * @param {Array<!BookmarkTreeNode>} items The loaded items.
       * @private
       */
      handleBookmarkCallback_: function(items) {
        this.loadCount_--;
        if (this.loadCount_)
          return;
  
        if (!items) {
          // Failed to load bookmarks. Most likely due to the bookmark being
          // removed.
          cr.dispatchSimpleEvent(this, 'invalidId');
          return;
        }
  
        this.dataModel = new BookmarksArrayDataModel(items);
  
        this.fixWidth_();
        cr.dispatchSimpleEvent(this, 'load');
      },
  
      /**
       * The bookmark node that the list is currently displaying. If we are
       * currently displaying search this returns null.
       * @type {BookmarkTreeNode}
       */
      get bookmarkNode() {
        if (this.isSearch())
          return null;
        var treeItem = bmm.treeLookup[this.parentId];
        return treeItem && treeItem.bookmarkNode;
      },
  
      /**
       * @return {boolean} Whether we are currently showing search results.
       */
      isSearch: function() {
        return this.parentId_[0] == 'q';
      },
  
      /**
       * @return {boolean} Whether we are editing an ephemeral item.
       */
      hasEphemeral: function() {
        var dataModel = this.dataModel;
        for (var i = 0; i < dataModel.array_.length; i++) {
          if (dataModel.array_[i].id == 'new')
            return true;
        }
        return false;
      },
  
      /**
       * Handles mouseover on the list so that we can add the context menu button
       * lazily.
       * @private
       * @param {!Event} e The mouseover event object.
       */
      handleMouseOver_: function(e) {
        var el = e.target;
        while (el && el.parentNode != this) {
          el = el.parentNode;
        }
  
        if (el && el.parentNode == this &&
            !el.editing &&
            !(el.lastChild instanceof ContextMenuButton)) {
          el.appendChild(new ContextMenuButton);
        }
      },
  
      /**
       * Dispatches an urlClicked event which is used to open URLs in new
       * tabs etc.
       * @private
       * @param {string} url The URL that was clicked.
       * @param {!Event} originalEvent The original click event object.
       */
      dispatchUrlClickedEvent_: function(url, originalEvent) {
        var event = new Event('urlClicked', {bubbles: true});
        event.url = url;
        event.originalEvent = originalEvent;
        this.dispatchEvent(event);
      },
  
      /**
       * Handles mousedown events so that we can prevent the auto scroll as
       * necessary.
       * @private
       * @param {!Event} e The mousedown event object.
       */
      handleMouseDown_: function(e) {
        e = /** @type {!MouseEvent} */(e);
        if (e.button == 1) {
          // WebKit no longer fires click events for middle clicks so we manually
          // listen to mouse up to dispatch a click event.
          this.addEventListener('mouseup', this.handleMiddleMouseUp_);
  
          // When the user does a middle click we need to prevent the auto scroll
          // in case the user is trying to middle click to open a bookmark in a
          // background tab.
          // We do not do this in case the target is an input since middle click
          // is also paste on Linux and we don't want to break that.
          if (e.target.tagName != 'INPUT')
            e.preventDefault();
        }
      },
  
      /**
       * WebKit no longer dispatches click events for middle clicks so we need
       * to emulate it.
       * @private
       * @param {!Event} e The mouse up event object.
       */
      handleMiddleMouseUp_: function(e) {
        e = /** @type {!MouseEvent} */(e);
        this.removeEventListener('mouseup', this.handleMiddleMouseUp_);
        if (e.button == 1) {
          var el = e.target;
          while (el.parentNode != this) {
            el = el.parentNode;
          }
          var node = el.bookmarkNode;
          if (node && !bmm.isFolder(node))
            this.dispatchUrlClickedEvent_(node.url, e);
        }
        e.preventDefault();
      },
  
      // Bookmark model update callbacks
      handleBookmarkChanged: function(id, changeInfo) {
        var dataModel = this.dataModel;
        var index = dataModel.findIndexById(id);
        if (index != -1) {
          var bookmarkNode = this.dataModel.item(index);
          bookmarkNode.title = changeInfo.title;
          if ('url' in changeInfo)
            bookmarkNode.url = changeInfo['url'];
  
          dataModel.updateIndex(index);
        }
      },
  
      /**
       * @param {string} id
       * @param {ReorderInfo} reorderInfo
       */
      handleChildrenReordered: function(id, reorderInfo) {
        if (this.parentId == id) {
          // We create a new data model with updated items in the right order.
          var dataModel = this.dataModel;
          var items = {};
          for (var i = this.dataModel.length - 1; i >= 0; i--) {
            var bookmarkNode = dataModel.item(i);
            items[bookmarkNode.id] = bookmarkNode;
          }
          var newArray = [];
          for (var i = 0; i < reorderInfo.childIds.length; i++) {
            newArray[i] = items[reorderInfo.childIds[i]];
            newArray[i].index = i;
          }
  
          this.dataModel = new BookmarksArrayDataModel(newArray);
        }
      },
  
      handleCreated: function(id, bookmarkNode) {
        if (this.parentId == bookmarkNode.parentId)
          this.dataModel.splice(bookmarkNode.index, 0, bookmarkNode);
      },
  
      /**
       * @param {string} id
       * @param {MoveInfo} moveInfo
       */
      handleMoved: function(id, moveInfo) {
        if (moveInfo.parentId == this.parentId ||
            moveInfo.oldParentId == this.parentId) {
  
          var dataModel = this.dataModel;
  
          if (moveInfo.oldParentId == moveInfo.parentId) {
            // Reorder within this folder
  
            this.startBatchUpdates();
  
            var bookmarkNode = this.dataModel.item(moveInfo.oldIndex);
            this.dataModel.splice(moveInfo.oldIndex, 1);
            this.dataModel.splice(moveInfo.index, 0, bookmarkNode);
  
            this.endBatchUpdates();
          } else {
            if (moveInfo.oldParentId == this.parentId) {
              // Move out of this folder
  
              var index = dataModel.findIndexById(id);
              if (index != -1)
                dataModel.splice(index, 1);
            }
  
            if (moveInfo.parentId == this.parentId) {
              // Move to this folder
              var self = this;
              chrome.bookmarks.get(id, function(bookmarkNodes) {
                var bookmarkNode = bookmarkNodes[0];
                dataModel.splice(bookmarkNode.index, 0, bookmarkNode);
              });
            }
          }
        }
      },
  
      handleRemoved: function(id, removeInfo) {
        var dataModel = this.dataModel;
        var index = dataModel.findIndexById(id);
        if (index != -1)
          dataModel.splice(index, 1);
      },
  
      /**
       * Workaround for http://crbug.com/40902
       * @private
       */
      fixWidth_: function() {
        var list = bmm.list;
        if (this.loadCount_ || !list)
          return;
  
        // The width of the list is wrong after its content has changed.
        // Fortunately the reported offsetWidth is correct so we can detect the
        //incorrect width.
        if (list.offsetWidth != list.parentNode.clientWidth - list.offsetLeft) {
          // Set the width to the correct size. This causes the relayout.
          list.style.width = list.parentNode.clientWidth - list.offsetLeft + 'px';
          // Remove the temporary style.width in a timeout. Once the timer fires
          // the size should not change since we already fixed the width.
          window.setTimeout(function() {
            list.style.width = '';
          }, 0);
        }
      }
    };
  
    /**
     * The ID of the bookmark folder we are displaying.
     */
    cr.defineProperty(BookmarkList, 'parentId', cr.PropertyKind.JS,
                      function() {
                        this.reload();
                      });
  
    /**
     * The contextMenu property.
     */
    cr.ui.contextMenuHandler.addContextMenuProperty(BookmarkList);
    /** @type {cr.ui.Menu} */
    BookmarkList.prototype.contextMenu;
  
    /**
     * Creates a new bookmark list item.
     * @param {!BookmarkTreeNode} bookmarkNode The bookmark node this represents.
     * @constructor
     * @extends {cr.ui.ListItem}
     */
    function BookmarkListItem(bookmarkNode) {
      var el = cr.doc.createElement('div');
      el.bookmarkNode = bookmarkNode;
      BookmarkListItem.decorate(el);
      return el;
    }
  
    /**
     * Decorates an element as a bookmark list item.
     * @param {!HTMLElement} el The element to decorate.
     */
    BookmarkListItem.decorate = function(el) {
      el.__proto__ = BookmarkListItem.prototype;
      el.decorate();
    };
  
    BookmarkListItem.prototype = {
      __proto__: ListItem.prototype,
  
      /** @override */
      decorate: function() {
        ListItem.prototype.decorate.call(this);
  
        var bookmarkNode = this.bookmarkNode;
  
        this.draggable = true;
  
        var labelEl = this.ownerDocument.createElement('div');
        labelEl.className = 'label';
        labelEl.textContent = bookmarkNode.title;
  
        var urlEl = this.ownerDocument.createElement('div');
        urlEl.className = 'url';
  
        if (bmm.isFolder(bookmarkNode)) {
          this.className = 'folder';
        } else {
          labelEl.style.backgroundImage = cr.icon.getFaviconImageSet(
              bookmarkNode.url);
          labelEl.style.backgroundSize = '16px';
          urlEl.textContent = bookmarkNode.url;
        }
  
        this.appendChild(labelEl);
        this.appendChild(urlEl);
  
        // Initially the ContextMenuButton was added here but it slowed down
        // rendering a lot so it is now added using mouseover.
      },
  
      /**
       * The ID of the bookmark folder we are currently showing or loading.
       * @type {string}
       */
      get bookmarkId() {
        return this.bookmarkNode.id;
      },
  
      /**
       * Whether the user is currently able to edit the list item.
       * @type {boolean}
       */
      get editing() {
        return this.hasAttribute('editing');
      },
      set editing(editing) {
        var oldEditing = this.editing;
        if (oldEditing == editing)
          return;
  
        var url = this.bookmarkNode.url;
        var title = this.bookmarkNode.title;
        var isFolder = bmm.isFolder(this.bookmarkNode);
        var listItem = this;
        var labelEl = this.firstChild;
        var urlEl = labelEl.nextSibling;
        var labelInput, urlInput;
  
        // Handles enter and escape which trigger reset and commit respectively.
        function handleKeydown(e) {
          // Make sure that the tree does not handle the key.
          e.stopPropagation();
  
          // Calling list.focus blurs the input which will stop editing the list
          // item.
          switch (e.keyIdentifier) {
            case 'U+001B':  // Esc
              labelInput.value = title;
              if (!isFolder)
                urlInput.value = url;
              // fall through
              cr.dispatchSimpleEvent(listItem, 'canceledit', true);
            case 'Enter':
              if (listItem.parentNode)
                listItem.parentNode.focus();
              break;
            case 'U+0009':  // Tab
              // urlInput is the last focusable element in the page.  If we
              // allowed Tab focus navigation and the page loses focus, we
              // couldn't give focus on urlInput programatically. So, we prevent
              // Tab focus navigation.
              if (document.activeElement == urlInput && !e.ctrlKey &&
                  !e.metaKey && !e.shiftKey && !getValidURL(urlInput)) {
                e.preventDefault();
                urlInput.blur();
              }
              break;
          }
        }
  
        function getValidURL(input) {
          var originalValue = input.value;
          if (!originalValue)
            return null;
          if (input.validity.valid)
            return originalValue;
          // Blink does not do URL fix up so we manually test if prepending
          // 'http://' would make the URL valid.
          // https://bugs.webkit.org/show_bug.cgi?id=29235
          input.value = 'http://' + originalValue;
          if (input.validity.valid)
            return input.value;
          // still invalid
          input.value = originalValue;
          return null;
        }
  
        function handleBlur(e) {
          // When the blur event happens we do not know who is getting focus so we
          // delay this a bit since we want to know if the other input got focus
          // before deciding if we should exit edit mode.
          var doc = e.target.ownerDocument;
          window.setTimeout(function() {
            var activeElement = doc.hasFocus() && doc.activeElement;
            if (activeElement != urlInput && activeElement != labelInput) {
              listItem.editing = false;
            }
          }, 50);
        }
  
        var doc = this.ownerDocument;
        if (editing) {
          this.setAttribute('editing', '');
          this.draggable = false;
  
          labelInput = /** @type {HTMLElement} */(doc.createElement('input'));
          labelInput.placeholder =
              loadTimeData.getString('name_input_placeholder');
          replaceAllChildren(labelEl, labelInput);
          labelInput.value = title;
  
          if (!isFolder) {
            urlInput = /** @type {HTMLElement} */(doc.createElement('input'));
            urlInput.type = 'url';
            urlInput.required = true;
            urlInput.placeholder =
                loadTimeData.getString('url_input_placeholder');
  
            // We also need a name for the input for the CSS to work.
            urlInput.name = '-url-input-' + cr.createUid();
            replaceAllChildren(assert(urlEl), urlInput);
            urlInput.value = url;
          }
  
          var stopPropagation = function(e) {
            e.stopPropagation();
          };
  
          var eventsToStop =
              ['mousedown', 'mouseup', 'contextmenu', 'dblclick', 'paste'];
          eventsToStop.forEach(function(type) {
            labelInput.addEventListener(type, stopPropagation);
          });
          labelInput.addEventListener('keydown', handleKeydown);
          labelInput.addEventListener('blur', handleBlur);
          cr.ui.limitInputWidth(labelInput, this, 100, 0.5);
          labelInput.focus();
          labelInput.select();
  
          if (!isFolder) {
            eventsToStop.forEach(function(type) {
              urlInput.addEventListener(type, stopPropagation);
            });
            urlInput.addEventListener('keydown', handleKeydown);
            urlInput.addEventListener('blur', handleBlur);
            cr.ui.limitInputWidth(urlInput, this, 200, 0.5);
          }
  
        } else {
          // Check that we have a valid URL and if not we do not change the
          // editing mode.
          if (!isFolder) {
            var urlInput = this.querySelector('.url input');
            var newUrl = urlInput.value;
            if (!newUrl) {
              cr.dispatchSimpleEvent(this, 'canceledit', true);
              return;
            }
  
            newUrl = getValidURL(urlInput);
            if (!newUrl) {
              // In case the item was removed before getting here we should
              // not alert.
              if (listItem.parentNode) {
                // Select the item again.
                var dataModel = this.parentNode.dataModel;
                var index = dataModel.indexOf(this.bookmarkNode);
                var sm = this.parentNode.selectionModel;
                sm.selectedIndex = sm.leadIndex = sm.anchorIndex = index;
  
                alert(loadTimeData.getString('invalid_url'));
              }
              urlInput.focus();
              urlInput.select();
              return;
            }
            urlEl.textContent = this.bookmarkNode.url = newUrl;
          }
  
          this.removeAttribute('editing');
          this.draggable = true;
  
          labelInput = this.querySelector('.label input');
          var newLabel = labelInput.value;
          labelEl.textContent = this.bookmarkNode.title = newLabel;
  
          if (isFolder) {
            if (newLabel != title) {
              cr.dispatchSimpleEvent(this, 'rename', true);
            }
          } else if (newLabel != title || newUrl != url) {
            cr.dispatchSimpleEvent(this, 'edit', true);
          }
        }
      }
    };
  
    return {
      BookmarkList: BookmarkList,
      list: /** @type {Element} */(null),  // Set when decorated.
    };
  });
  