Javascript  |  1593行  |  46.16 KB

// Copyright (c) 2011 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.

const BookmarkList = bmm.BookmarkList;
const BookmarkTree = bmm.BookmarkTree;
const ListItem = cr.ui.ListItem;
const TreeItem = cr.ui.TreeItem;
const LinkKind = cr.LinkKind;
const Command = cr.ui.Command;
const CommandBinding = cr.ui.CommandBinding;
const Menu = cr.ui.Menu;
const MenuButton  = cr.ui.MenuButton;
const Promise = cr.Promise;

// Sometimes the extension API is not initialized.
if (!chrome.bookmarks)
  console.error('Bookmarks extension API is not available');

// Allow platform specific CSS rules.
if (cr.isMac)
  document.documentElement.setAttribute('os', 'mac');

/**
 * The local strings object which is used to do the translation.
 * @type {!LocalStrings}
 */
var localStrings = new LocalStrings;

// Get the localized strings from the backend.
chrome.experimental.bookmarkManager.getStrings(function(data) {
  // The strings may contain & which we need to strip.
  for (var key in data) {
    data[key] = data[key].replace(/&/, '');
  }

  localStrings.templateData = data;
  i18nTemplate.process(document, data);

  recentTreeItem.label = localStrings.getString('recent');
  searchTreeItem.label = localStrings.getString('search');
});

/**
 * The id of the bookmark root.
 * @type {number}
 */
const ROOT_ID = '0';

var bookmarkCache = {
  /**
   * Removes the cached item from both the list and tree lookups.
   */
  remove: function(id) {
    var treeItem = bmm.treeLookup[id];
    if (treeItem) {
      var items = treeItem.items; // is an HTMLCollection
      for (var i = 0, item; item = items[i]; i++) {
        var bookmarkNode = item.bookmarkNode;
        delete bmm.treeLookup[bookmarkNode.id];
      }
      delete bmm.treeLookup[id];
    }
  },

  /**
   * Updates the underlying bookmark node for the tree items and list items by
   * querying the bookmark backend.
   * @param {string} id The id of the node to update the children for.
   * @param {Function=} opt_f A funciton to call when done.
   */
  updateChildren: function(id, opt_f) {
    function updateItem(bookmarkNode) {
      var treeItem = bmm.treeLookup[bookmarkNode.id];
      if (treeItem) {
        treeItem.bookmarkNode = bookmarkNode;
      }
    }

    chrome.bookmarks.getChildren(id, function(children) {
      if (children)
        children.forEach(updateItem);

      if (opt_f)
        opt_f(children);
    });
  }
};

var splitter = document.querySelector('.main > .splitter');
cr.ui.Splitter.decorate(splitter);

// The splitter persists the size of the left component in the local store.
if ('treeWidth' in localStorage)
  splitter.previousElementSibling.style.width = localStorage['treeWidth'];
splitter.addEventListener('resize', function(e) {
  localStorage['treeWidth'] = splitter.previousElementSibling.style.width;
});

BookmarkList.decorate(list);

var searchTreeItem = new TreeItem({
  icon: 'images/bookmark_manager_search.png',
  bookmarkId: 'q='
});
bmm.treeLookup[searchTreeItem.bookmarkId] = searchTreeItem;

var recentTreeItem = new TreeItem({
  icon: 'images/bookmark_manager_recent.png',
  bookmarkId: 'recent'
});
bmm.treeLookup[recentTreeItem.bookmarkId] = recentTreeItem;

BookmarkTree.decorate(tree);

tree.addEventListener('change', function() {
  navigateTo(tree.selectedItem.bookmarkId);
});

/**
 * Navigates to a bookmark ID.
 * @param {string} id The ID to navigate to.
 * @param {boolean=} opt_updateHashNow Whether to immediately update the
 *     location.hash. If false then it is updated in a timeout.
 */
function navigateTo(id, opt_updateHashNow) {
  console.info('navigateTo', 'from', window.location.hash, 'to', id);
  // Update the location hash using a timer to prevent reentrancy. This is how
  // often we add history entries and the time here is a bit arbitrary but was
  // picked as the smallest time a human perceives as instant.

  function f() {
    window.location.hash = tree.selectedItem.bookmarkId;
  }

  clearTimeout(navigateTo.timer_);
  if (opt_updateHashNow)
    f();
  else
    navigateTo.timer_ = setTimeout(f, 250);

  updateParentId(id);
}

/**
 * Updates the parent ID of the bookmark list and selects the correct tree item.
 * @param {string} id The id.
 */
function updateParentId(id) {
  list.parentId = id;
  if (id in bmm.treeLookup)
    tree.selectedItem = bmm.treeLookup[id];
}

// We listen to hashchange so that we can update the currently shown folder when
// the user goes back and forward in the history.
window.onhashchange = function(e) {
  var id = window.location.hash.slice(1);

  var valid = false;

  // In case we got a search hash update the text input and the bmm.treeLookup
  // to use the new id.
  if (/^q=/.test(id)) {
    setSearch(id.slice(2));
    valid = true;
  } else if (id == 'recent') {
    valid = true;
  }

  if (valid) {
    updateParentId(id);
  } else {
    // We need to verify that this is a correct ID.
    chrome.bookmarks.get(id, function(items) {
      if (items && items.length == 1)
        updateParentId(id);
    });
  }
};

// Activate is handled by the open-in-same-window-command.
list.addEventListener('dblclick', function(e) {
  if (e.button == 0)
    $('open-in-same-window-command').execute();
});

// The list dispatches an event when the user clicks on the URL or the Show in
// folder part.
list.addEventListener('urlClicked', function(e) {
  getLinkController().openUrlFromEvent(e.url, e.originalEvent);
});

$('term').onsearch = function(e) {
  setSearch(this.value);
};

/**
 * Navigates to the search results for the search text.
 * @para {string} searchText The text to search for.
 */
function setSearch(searchText) {
  if (searchText) {
    // Only update search item if we have a search term. We never want the
    // search item to be for an empty search.
    delete bmm.treeLookup[searchTreeItem.bookmarkId];
    var id = searchTreeItem.bookmarkId = 'q=' + searchText;
    bmm.treeLookup[searchTreeItem.bookmarkId] = searchTreeItem;
  }

  var input = $('term');
  // Do not update the input if the user is actively using the text input.
  if (document.activeElement != input)
    input.value = searchText;

  if (searchText) {
    tree.add(searchTreeItem);
    tree.selectedItem = searchTreeItem;
  } else {
    // Go "home".
    tree.selectedItem = tree.items[0];
    id = tree.selectedItem.bookmarkId;
  }

  // Navigate now and update hash immediately.
  navigateTo(id, true);
}

// Handle the logo button UI.
// When the user clicks the button we should navigate "home" and focus the list
document.querySelector('button.logo').onclick = function(e) {
  setSearch('');
  $('list').focus();
};

/**
 * Called when the title of a bookmark changes.
 * @param {string} id
 * @param {!Object} changeInfo
 */
function handleBookmarkChanged(id, changeInfo) {
  // console.info('handleBookmarkChanged', id, changeInfo);
  list.handleBookmarkChanged(id, changeInfo);
  tree.handleBookmarkChanged(id, changeInfo);
}

/**
 * Callback for when the user reorders by title.
 * @param {string} id The id of the bookmark folder that was reordered.
 * @param {!Object} reorderInfo The information about how the items where
 *     reordered.
 */
function handleChildrenReordered(id, reorderInfo) {
  // console.info('handleChildrenReordered', id, reorderInfo);
  list.handleChildrenReordered(id, reorderInfo);
  tree.handleChildrenReordered(id, reorderInfo);
  bookmarkCache.updateChildren(id);
}

/**
 * Callback for when a bookmark node is created.
 * @param {string} id The id of the newly created bookmark node.
 * @param {!Object} bookmarkNode The new bookmark node.
 */
function handleCreated(id, bookmarkNode) {
  // console.info('handleCreated', id, bookmarkNode);
  list.handleCreated(id, bookmarkNode);
  tree.handleCreated(id, bookmarkNode);
  bookmarkCache.updateChildren(bookmarkNode.parentId);
}

function handleMoved(id, moveInfo) {
  // console.info('handleMoved', id, moveInfo);
  list.handleMoved(id, moveInfo);
  tree.handleMoved(id, moveInfo);

  bookmarkCache.updateChildren(moveInfo.parentId);
  if (moveInfo.parentId != moveInfo.oldParentId)
    bookmarkCache.updateChildren(moveInfo.oldParentId);
}

function handleRemoved(id, removeInfo) {
  // console.info('handleRemoved', id, removeInfo);
  list.handleRemoved(id, removeInfo);
  tree.handleRemoved(id, removeInfo);

  bookmarkCache.updateChildren(removeInfo.parentId);
  bookmarkCache.remove(id);
}

function handleImportBegan() {
  chrome.bookmarks.onCreated.removeListener(handleCreated);
  chrome.bookmarks.onChanged.removeListener(handleBookmarkChanged);
}

function handleImportEnded() {
  // When importing is done we reload the tree and the list.

  function f() {
    tree.removeEventListener('load', f);

    chrome.bookmarks.onCreated.addListener(handleCreated);
    chrome.bookmarks.onChanged.addListener(handleBookmarkChanged);

    if (list.selectImportedFolder) {
      var otherBookmarks = tree.items[1].items;
      var importedFolder = otherBookmarks[otherBookmarks.length - 1];
      navigateTo(importedFolder.bookmarkId)
      list.selectImportedFolder = false
    } else {
      list.reload();
    }
  }

  tree.addEventListener('load', f);
  tree.reload();
}

/**
 * Adds the listeners for the bookmark model change events.
 */
function addBookmarkModelListeners() {
  chrome.bookmarks.onChanged.addListener(handleBookmarkChanged);
  chrome.bookmarks.onChildrenReordered.addListener(handleChildrenReordered);
  chrome.bookmarks.onCreated.addListener(handleCreated);
  chrome.bookmarks.onMoved.addListener(handleMoved);
  chrome.bookmarks.onRemoved.addListener(handleRemoved);
  chrome.bookmarks.onImportBegan.addListener(handleImportBegan);
  chrome.bookmarks.onImportEnded.addListener(handleImportEnded);
}

/**
 * This returns the user visible path to the folder where the bookmark is
 * located.
 * @param {number} parentId The ID of the parent folder.
 * @return {string} The path to the the bookmark,
 */
function getFolder(parentId) {
  var parentNode = tree.getBookmarkNodeById(parentId);
  if (parentNode) {
    var s = parentNode.title;
    if (parentNode.parentId != ROOT_ID) {
      return getFolder(parentNode.parentId) + '/' + s;
    }
    return s;
  }
}

tree.addEventListener('load', function(e) {
  // Add hard coded tree items
  tree.add(recentTreeItem);

  // Now we can select a tree item.
  var hash = window.location.hash.slice(1);
  if (!hash) {
    // If we do not have a hash select first item in the tree.
    hash = tree.items[0].bookmarkId;
  }

  if (/^q=/.test(hash)) {
    var searchTerm = hash.slice(2);
    $('term').value = searchTerm;
    setSearch(searchTerm);
  } else {
    navigateTo(hash);
  }
});

tree.reload();
addBookmarkModelListeners();

var dnd = {
  dragData: null,

  getBookmarkElement: function(el) {
    while (el && !el.bookmarkNode) {
      el = el.parentNode;
    }
    return el;
  },

  // If we are over the list and the list is showing recent or search result
  // we cannot drop.
  isOverRecentOrSearch: function(overElement) {
    return (list.isRecent() || list.isSearch()) && list.contains(overElement);
  },

  checkEvery_: function(f, overBookmarkNode, overElement) {
    return this.dragData.elements.every(function(element) {
      return f.call(this, element, overBookmarkNode, overElement);
    }, this);
  },

  /**
   * @return {boolean} Whether we are currently dragging any folders.
   */
  isDraggingFolders: function() {
    return !!this.dragData && this.dragData.elements.some(function(node) {
      return !node.url;
    });
  },

  /**
   * This is a first pass wether we can drop the dragged items.
   *
   * @param {!BookmarkTreeNode} overBookmarkNode The bookmark that we are
   *     currently dragging over.
   * @param {!HTMLElement} overElement The element that we are currently
   *     dragging over.
   * @return {boolean} If this returns false then we know we should not drop
   *     the items. If it returns true we still have to call canDropOn,
   *     canDropAbove and canDropBelow.
   */
  canDrop: function(overBookmarkNode, overElement) {
    var dragData = this.dragData;
    if (!dragData)
      return false;

    if (this.isOverRecentOrSearch(overElement))
      return false;

    if (!dragData.sameProfile)
      return true;

    return this.checkEvery_(this.canDrop_, overBookmarkNode, overElement);
  },

  /**
   * Helper for canDrop that only checks one bookmark node.
   * @private
   */
  canDrop_: function(dragNode, overBookmarkNode, overElement) {
    var dragId = dragNode.id;

    if (overBookmarkNode.id == dragId)
      return false;

    // If we are dragging a folder we cannot drop it on any of its descendants
    var dragBookmarkItem = bmm.treeLookup[dragId];
    var dragBookmarkNode = dragBookmarkItem && dragBookmarkItem.bookmarkNode;
    if (dragBookmarkNode && bmm.contains(dragBookmarkNode, overBookmarkNode)) {
      return false;
    }

    return true;
  },

  /**
   * Whether we can drop the dragged items above the drop target.
   *
   * @param {!BookmarkTreeNode} overBookmarkNode The bookmark that we are
   *     currently dragging over.
   * @param {!HTMLElement} overElement The element that we are currently
   *     dragging over.
   * @return {boolean} Whether we can drop the dragged items above the drop
   *     target.
   */
  canDropAbove: function(overBookmarkNode, overElement) {
    if (overElement instanceof BookmarkList)
      return false;

    // We cannot drop between Bookmarks bar and Other bookmarks
    if (overBookmarkNode.parentId == ROOT_ID)
      return false;

    var isOverTreeItem = overElement instanceof TreeItem;

    // We can only drop between items in the tree if we have any folders.
    if (isOverTreeItem && !this.isDraggingFolders())
      return false;

    if (!this.dragData.sameProfile)
      return this.isDraggingFolders() || !isOverTreeItem;

    return this.checkEvery_(this.canDropAbove_, overBookmarkNode, overElement);
  },

  /**
   * Helper for canDropAbove that only checks one bookmark node.
   * @private
   */
  canDropAbove_: function(dragNode, overBookmarkNode, overElement) {
    var dragId = dragNode.id;

    // We cannot drop above if the item below is already in the drag source
    var previousElement = overElement.previousElementSibling;
    if (previousElement &&
        previousElement.bookmarkId == dragId)
      return false;

    return true;
  },

  /**
   * Whether we can drop the dragged items below the drop target.
   *
   * @param {!BookmarkTreeNode} overBookmarkNode The bookmark that we are
   *     currently dragging over.
   * @param {!HTMLElement} overElement The element that we are currently
   *     dragging over.
   * @return {boolean} Whether we can drop the dragged items below the drop
   *     target.
   */
  canDropBelow: function(overBookmarkNode, overElement) {
    if (overElement instanceof BookmarkList)
      return false;

    // We cannot drop between Bookmarks bar and Other bookmarks
    if (overBookmarkNode.parentId == ROOT_ID)
      return false;

    // We can only drop between items in the tree if we have any folders.
    if (!this.isDraggingFolders() && overElement instanceof TreeItem)
      return false;

    var isOverTreeItem = overElement instanceof TreeItem;

    // Don't allow dropping below an expanded tree item since it is confusing
    // to the user anyway.
    if (isOverTreeItem && overElement.expanded)
      return false;

    if (!this.dragData.sameProfile)
      return this.isDraggingFolders() || !isOverTreeItem;

    return this.checkEvery_(this.canDropBelow_, overBookmarkNode, overElement);
  },

  /**
   * Helper for canDropBelow that only checks one bookmark node.
   * @private
   */
  canDropBelow_: function(dragNode, overBookmarkNode, overElement) {
    var dragId = dragNode.id;

    // We cannot drop below if the item below is already in the drag source
    var nextElement = overElement.nextElementSibling;
    if (nextElement &&
        nextElement.bookmarkId == dragId)
      return false;

    return true;
  },

  /**
   * Whether we can drop the dragged items on the drop target.
   *
   * @param {!BookmarkTreeNode} overBookmarkNode The bookmark that we are
   *     currently dragging over.
   * @param {!HTMLElement} overElement The element that we are currently
   *     dragging over.
   * @return {boolean} Whether we can drop the dragged items on the drop
   *     target.
   */
  canDropOn: function(overBookmarkNode, overElement) {
    // We can only drop on a folder.
    if (!bmm.isFolder(overBookmarkNode))
      return false;

    if (!this.dragData.sameProfile)
      return true;

    return this.checkEvery_(this.canDropOn_, overBookmarkNode, overElement);
  },

  /**
   * Helper for canDropOn that only checks one bookmark node.
   * @private
   */
  canDropOn_: function(dragNode, overBookmarkNode, overElement) {
    var dragId = dragNode.id;

    if (overElement instanceof BookmarkList) {
      // We are trying to drop an item after the last item in the list. This
      // is allowed if the item is different from the last item in the list
      var listItems = list.items;
      var len = listItems.length;
      if (len == 0 ||
          listItems[len - 1].bookmarkId != dragId) {
        return true;
      }
    }

    // Cannot drop on current parent.
    if (overBookmarkNode.id == dragNode.parentId)
      return false;

    return true;
  },

  /**
   * Callback for the dragstart event.
   * @param {Event} e The dragstart event.
   */
  handleDragStart: function(e) {
    // Determine the selected bookmarks.
    var target = e.target;
    var draggedNodes = [];
    if (target instanceof ListItem) {
      // Use selected items.
      draggedNodes = target.parentNode.selectedItems;
    } else if (target instanceof TreeItem) {
      draggedNodes.push(target.bookmarkNode);
    }

    // We manage starting the drag by using the extension API.
    e.preventDefault();

    if (draggedNodes.length) {
      // If we are dragging a single link we can do the *Link* effect, otherwise
      // we only allow copy and move.
      var effectAllowed;
      if (draggedNodes.length == 1 &&
          !bmm.isFolder(draggedNodes[0])) {
        effectAllowed = 'copyMoveLink';
      } else {
        effectAllowed = 'copyMove';
      }
      e.dataTransfer.effectAllowed = effectAllowed;

      var ids = draggedNodes.map(function(node) {
        return node.id;
      });

      chrome.experimental.bookmarkManager.startDrag(ids);
    }
  },

  handleDragEnter: function(e) {
    e.preventDefault();
  },

  /**
   * Calback for the dragover event.
   * @param {Event} e The dragover event.
   */
  handleDragOver: function(e) {
    // TODO(arv): This function is way too long. Please refactor it.

    // Allow DND on text inputs.
    if (e.target.tagName != 'INPUT') {
      // The default operation is to allow dropping links etc to do navigation.
      // We never want to do that for the bookmark manager.
      e.preventDefault();

      // Set to none. This will get set to something if we can do the drop.
      e.dataTransfer.dropEffect = 'none';
    }

    if (!this.dragData)
      return;

    var overElement = this.getBookmarkElement(e.target);
    if (!overElement && e.target == list)
      overElement = list;

    if (!overElement)
      return;

    var overBookmarkNode = overElement.bookmarkNode;

    if (!this.canDrop(overBookmarkNode, overElement))
      return;

    var bookmarkNode = overElement.bookmarkNode;

    var canDropAbove = this.canDropAbove(overBookmarkNode, overElement);
    var canDropOn = this.canDropOn(overBookmarkNode, overElement);
    var canDropBelow = this.canDropBelow(overBookmarkNode, overElement);

    if (!canDropAbove && !canDropOn && !canDropBelow)
      return;

    // Now we know that we can drop. Determine if we will drop above, on or
    // below based on mouse position etc.

    var dropPos;

    e.dataTransfer.dropEffect = this.dragData.sameProfile ? 'move' : 'copy';

    var rect;
    if (overElement instanceof TreeItem) {
      // We only want the rect of the row representing the item and not
      // its children
      rect = overElement.rowElement.getBoundingClientRect();
    } else {
      rect = overElement.getBoundingClientRect();
    }

    var dy = e.clientY - rect.top;
    var yRatio = dy / rect.height;

    //  above
    if (canDropAbove &&
        (yRatio <= .25 || yRatio <= .5 && !(canDropBelow && canDropOn))) {
      dropPos = 'above';

    // below
    } else if (canDropBelow &&
               (yRatio > .75 || yRatio > .5 && !(canDropAbove && canDropOn))) {
      dropPos = 'below';

    // on
    } else if (canDropOn) {
      dropPos = 'on';

    // none
    } else {
      // No drop can happen. Exit now.
      e.dataTransfer.dropEffect = 'none';
      return;
    }

    function cloneClientRect(rect) {
      var newRect = {};
      for (var key in rect) {
        newRect[key] = rect[key];
      }
      return newRect;
    }

    // If we are dropping above or below a tree item adjust the width so
    // that it is clearer where the item will be dropped.
    if ((dropPos == 'above' || dropPos == 'below') &&
        overElement instanceof TreeItem) {
      // ClientRect is read only so clone in into a read-write object.
      rect = cloneClientRect(rect);
      var rtl = getComputedStyle(overElement).direction == 'rtl';
      var labelElement = overElement.labelElement;
      var labelRect = labelElement.getBoundingClientRect();
      if (rtl) {
        rect.width = labelRect.left + labelRect.width - rect.left;
      } else {
        rect.left = labelRect.left;
        rect.width -= rect.left
      }
    }

    var overlayType = dropPos;

    // If we are dropping on a list we want to show a overlay drop line after
    // the last element
    if (overElement instanceof BookmarkList) {
      overlayType = 'below';

      // Get the rect of the last list item.
      var length = overElement.dataModel.length;
      if (length) {
        dropPos = 'below';
        overElement = overElement.getListItemByIndex(length - 1);
        rect = overElement.getBoundingClientRect();
      } else {
        // If there are no items, collapse the height of the rect
        rect = cloneClientRect(rect);
        rect.height = 0;
        // We do not use bottom so we don't care to adjust it.
      }
    }

    this.showDropOverlay_(rect, overlayType);

    this.dropDestination = {
      dropPos: dropPos,
      relatedNode: overElement.bookmarkNode
    };
  },

  /**
   * Shows and positions the drop marker overlay.
   * @param {ClientRect} targetRect The drop target rect
   * @param {string} overlayType The position relative to the target rect.
   * @private
   */
  showDropOverlay_: function(targetRect, overlayType) {
    window.clearTimeout(this.hideDropOverlayTimer_);
    var overlay = $('drop-overlay');
    if (overlayType == 'on') {
      overlay.className = '';
      overlay.style.top = targetRect.top + 'px';
      overlay.style.height = targetRect.height + 'px';
    } else {
      overlay.className = 'line';
      overlay.style.height = '';
    }
    overlay.style.width = targetRect.width + 'px';
    overlay.style.left = targetRect.left + 'px';
    overlay.style.display = 'block';

    if (overlayType != 'on') {
      var overlayRect = overlay.getBoundingClientRect();
      if (overlayType == 'above') {
        overlay.style.top = targetRect.top - overlayRect.height / 2 + 'px';
      } else {
        overlay.style.top = targetRect.top + targetRect.height -
            overlayRect.height / 2 + 'px';
      }
    }
  },

  /**
   * Hides the drop overlay element.
   * @private
   */
  hideDropOverlay_: function() {
    // Hide the overlay in a timeout to reduce flickering as we move between
    // valid drop targets.
    window.clearTimeout(this.hideDropOverlayTimer_);
    this.hideDropOverlayTimer_ = window.setTimeout(function() {
      $('drop-overlay').style.display = '';
    }, 100);
  },

  handleDragLeave: function(e) {
    this.hideDropOverlay_();
  },

  handleDrop: function(e) {
    if (this.dropDestination && this.dragData) {
      var dropPos = this.dropDestination.dropPos;
      var relatedNode = this.dropDestination.relatedNode;
      var parentId = dropPos == 'on' ? relatedNode.id : relatedNode.parentId;

      var selectTarget;
      var selectedTreeId;
      var index;
      var relatedIndex;
      // Try to find the index in the dataModel so we don't have to always keep
      // the index for the list items up to date.
      var overElement = this.getBookmarkElement(e.target);
      if (overElement instanceof ListItem) {
        relatedIndex = overElement.parentNode.dataModel.indexOf(relatedNode);
        selectTarget = list;
      } else if (overElement instanceof BookmarkList) {
        relatedIndex = overElement.dataModel.length - 1;
        selectTarget = list;
      } else {
        // Tree
        relatedIndex = relatedNode.index;
        selectTarget = tree;
        selectedTreeId =
            tree.selectedItem ? tree.selectedItem.bookmarkId : null;
      }

      if (dropPos == 'above')
        index = relatedIndex;
      else if (dropPos == 'below')
        index = relatedIndex + 1;

      selectItemsAfterUserAction(selectTarget, selectedTreeId);

      if (index != undefined && index != -1)
        chrome.experimental.bookmarkManager.drop(parentId, index);
      else
        chrome.experimental.bookmarkManager.drop(parentId);

      // TODO(arv): Select the newly dropped items.
    }
    this.dropDestination = null;
    this.hideDropOverlay_();
  },

  clearDragData: function() {
    this.dragData = null;
  },

  handleChromeDragEnter: function(dragData) {
    this.dragData = dragData;
  },

  init: function() {
    var boundClearData = this.clearDragData.bind(this);
    function deferredClearData() {
      setTimeout(boundClearData);
    }

    document.addEventListener('dragstart', this.handleDragStart.bind(this));
    document.addEventListener('dragenter', this.handleDragEnter.bind(this));
    document.addEventListener('dragover', this.handleDragOver.bind(this));
    document.addEventListener('dragleave', this.handleDragLeave.bind(this));
    document.addEventListener('drop', this.handleDrop.bind(this));
    document.addEventListener('dragend', deferredClearData);
    document.addEventListener('mouseup', deferredClearData);

    chrome.experimental.bookmarkManager.onDragEnter.addListener(
        this.handleChromeDragEnter.bind(this));
    chrome.experimental.bookmarkManager.onDragLeave.addListener(
        deferredClearData);
    chrome.experimental.bookmarkManager.onDrop.addListener(deferredClearData);
  }
};

dnd.init();

// Commands

cr.ui.decorate('menu', Menu);
cr.ui.decorate('button[menu]', MenuButton);
cr.ui.decorate('command', Command);

cr.ui.contextMenuHandler.addContextMenuProperty(tree);
list.contextMenu = $('context-menu');
tree.contextMenu = $('context-menu');

// Disable almost all commands at startup.
var commands = document.querySelectorAll('command');
for (var i = 0, command; command = commands[i]; i++) {
  if (command.id != 'import-menu-command' &&
      command.id != 'export-menu-command') {
    command.disabled = true;
  }
}

/**
 * Helper function that updates the canExecute and labels for the open like
 * commands.
 * @param {!cr.ui.CanExecuteEvent} e The event fired by the command system.
 * @param {!cr.ui.Command} command The command we are currently precessing.
 */
function updateOpenCommands(e, command) {
  var selectedItem = e.target.selectedItem;
  var selectionCount;
  if (e.target == tree) {
    selectionCount = selectedItem ? 1 : 0;
    selectedItem = selectedItem.bookmarkNode;
  } else {
    selectionCount = e.target.selectedItems.length;
  }

  var isFolder = selectionCount == 1 &&
                 selectedItem &&
                 bmm.isFolder(selectedItem);
  var multiple = selectionCount != 1 || isFolder;

  function hasBookmarks(node) {
    for (var i = 0; i < node.children.length; i++) {
      if (!bmm.isFolder(node.children[i]))
        return true;
    }
    return false;
  }

  switch (command.id) {
    case 'open-in-new-tab-command':
      command.label = localStrings.getString(multiple ?
          'open_all' : 'open_in_new_tab');
      break;

    case 'open-in-new-window-command':
      command.label = localStrings.getString(multiple ?
          'open_all_new_window' : 'open_in_new_window');
      break;
    case 'open-incognito-window-command':
      command.label = localStrings.getString(multiple ?
          'open_all_incognito' : 'open_incognito');
      break;
  }
  e.canExecute = selectionCount > 0 && !!selectedItem;
  if (isFolder && e.canExecute) {
    // We need to get all the bookmark items in this tree. If the tree does not
    // contain any non-folders we need to disable the command.
    var p = bmm.loadSubtree(selectedItem.id);
    p.addListener(function(node) {
      command.disabled = !node || !hasBookmarks(node);
    });
  }
}

/**
 * Calls the backend to figure out if we can paste the clipboard into the active
 * folder.
 * @param {Function=} opt_f Function to call after the state has been
 *     updated.
 */
function updatePasteCommand(opt_f) {
  function update(canPaste) {
    var command = $('paste-command');
    command.disabled = !canPaste;
    if (opt_f)
      opt_f();
  }
  // We cannot paste into search and recent view.
  if (list.isSearch() || list.isRecent()) {
    update(false);
  } else {
    chrome.experimental.bookmarkManager.canPaste(list.parentId, update);
  }
}

// We can always execute the import-menu and export-menu commands.
document.addEventListener('canExecute', function(e) {
  var command = e.command;
  var commandId = command.id;
  if (commandId == 'import-menu-command' ||
      commandId == 'export-menu-command') {
    e.canExecute = true;
  }
});

/**
 * Helper function for handling canExecute for the list and the tree.
 * @param {!Event} e Can exectue event object.
 * @param {boolean} isRecentOrSearch Whether the user is trying to do a command
 *     on recent or search.
 */
function canExcuteShared(e, isRecentOrSearch) {
  var command = e.command;
  var commandId = command.id;
  switch (commandId) {
    case 'paste-command':
      updatePasteCommand();
      break;

    case 'sort-command':
      if (isRecentOrSearch) {
        e.canExecute = false;
      } else {
        e.canExecute = list.dataModel.length > 0;

        // The list might be loading so listen to the load event.
        var f = function() {
          list.removeEventListener('load', f);
          command.disabled = list.dataModel.length == 0;
        };
        list.addEventListener('load', f);
      }
      break;

    case 'add-new-bookmark-command':
    case 'new-folder-command':
      e.canExecute = !isRecentOrSearch;
      break;

    case 'open-in-new-tab-command':
    case 'open-in-background-tab-command':
    case 'open-in-new-window-command':
    case 'open-incognito-window-command':
      updateOpenCommands(e, command);
      break;
  }
}

// Update canExecute for the commands when the list is the active element.
list.addEventListener('canExecute', function(e) {
  if (e.target != list) return;

  var command = e.command;
  var commandId = command.id;

  function hasSelected() {
    return !!e.target.selectedItem;
  }

  function hasSingleSelected() {
    return e.target.selectedItems.length == 1;
  }

  function isRecentOrSearch() {
    return list.isRecent() || list.isSearch();
  }

  switch (commandId) {
    case 'rename-folder-command':
      // Show rename if a single folder is selected
      var items = e.target.selectedItems;
      if (items.length != 1) {
        e.canExecute = false;
        command.hidden = true;
      } else {
        var isFolder = bmm.isFolder(items[0]);
        e.canExecute = isFolder;
        command.hidden = !isFolder;
      }
      break;

    case 'edit-command':
      // Show the edit command if not a folder
      var items = e.target.selectedItems;
      if (items.length != 1) {
        e.canExecute = false;
        command.hidden = false;
      } else {
        var isFolder = bmm.isFolder(items[0]);
        e.canExecute = !isFolder;
        command.hidden = isFolder;
      }
      break;

    case 'show-in-folder-command':
      e.canExecute = isRecentOrSearch() && hasSingleSelected();
      break;

    case 'delete-command':
    case 'cut-command':
    case 'copy-command':
      e.canExecute = hasSelected();
      break;

    case 'open-in-same-window-command':
      e.canExecute = hasSelected();
      break;

    default:
      canExcuteShared(e, isRecentOrSearch());
  }
});

// Update canExecute for the commands when the tree is the active element.
tree.addEventListener('canExecute', function(e) {
  if (e.target != tree) return;

  var command = e.command;
  var commandId = command.id;

  function hasSelected() {
    return !!e.target.selectedItem;
  }

  function isRecentOrSearch() {
    var item = e.target.selectedItem;
    return item == recentTreeItem || item == searchTreeItem;
  }

  function isTopLevelItem() {
    return e.target.selectedItem.parentNode == tree;
  }

  switch (commandId) {
    case 'rename-folder-command':
      command.hidden = false;
      e.canExecute = hasSelected() && !isTopLevelItem();
      break;

    case 'edit-command':
      command.hidden = true;
      e.canExecute = false;
      break;

    case 'delete-command':
    case 'cut-command':
    case 'copy-command':
      e.canExecute = hasSelected() && !isTopLevelItem();
      break;

    default:
      canExcuteShared(e, isRecentOrSearch());
  }
});

/**
 * Update the canExecute state of the commands when the selection changes.
 * @param {Event} e The change event object.
 */
function updateCommandsBasedOnSelection(e) {
  if (e.target == document.activeElement) {
    // Paste only needs to updated when the tree selection changes.
    var commandNames = ['copy', 'cut', 'delete', 'rename-folder', 'edit',
        'add-new-bookmark', 'new-folder', 'open-in-new-tab',
        'open-in-new-window', 'open-incognito-window', 'open-in-same-window'];

    if (e.target == tree) {
      commandNames.push('paste', 'show-in-folder', 'sort');
    }

    commandNames.forEach(function(baseId) {
      $(baseId + '-command').canExecuteChange();
    });
  }
}

list.addEventListener('change', updateCommandsBasedOnSelection);
tree.addEventListener('change', updateCommandsBasedOnSelection);

document.addEventListener('command', function(e) {
  var command = e.command;
  var commandId = command.id;
  console.log(command.id, 'executed', 'on', e.target);
  if (commandId == 'import-menu-command') {
    // Set a flag on the list so we can select the newly imported folder.
    list.selectImportedFolder = true;
    chrome.bookmarks.import();
  } else if (command.id == 'export-menu-command') {
    chrome.bookmarks.export();
  }
});

function handleRename(e) {
  var item = e.target;
  var bookmarkNode = item.bookmarkNode;
  chrome.bookmarks.update(bookmarkNode.id, {title: item.label});
}

tree.addEventListener('rename', handleRename);
list.addEventListener('rename', handleRename);

list.addEventListener('edit', function(e) {
  var item = e.target;
  var bookmarkNode = item.bookmarkNode;
  var context = {
    title: bookmarkNode.title
  };
  if (!bmm.isFolder(bookmarkNode))
    context.url = bookmarkNode.url;

  if (bookmarkNode.id == 'new') {
    selectItemsAfterUserAction(list);

    // New page
    context.parentId = bookmarkNode.parentId;
    chrome.bookmarks.create(context, function(node) {
      // A new node was created and will get added to the list due to the
      // handler.
      var dataModel = list.dataModel;
      var index = dataModel.indexOf(bookmarkNode);
      dataModel.splice(index, 1);

      // Select new item.
      var newIndex = dataModel.findIndexById(node.id);
      if (newIndex != -1) {
        var sm = list.selectionModel;
        list.scrollIndexIntoView(newIndex);
        sm.leadIndex = sm.anchorIndex = sm.selectedIndex = newIndex;
      }
    });
  } else {
    // Edit
    chrome.bookmarks.update(bookmarkNode.id, context);
  }
});

list.addEventListener('canceledit', function(e) {
  var item = e.target;
  var bookmarkNode = item.bookmarkNode;
  if (bookmarkNode.id == 'new') {
    var dataModel = list.dataModel;
    var index = dataModel.findIndexById('new');
    dataModel.splice(index, 1);
  }
});

/**
 * Navigates to the folder that the selected item is in and selects it. This is
 * used for the show-in-folder command.
 */
function showInFolder() {
  var bookmarkNode = list.selectedItem;
  var parentId = bookmarkNode.parentId;

  // After the list is loaded we should select the revealed item.
  function f(e) {
    var index;
    if (bookmarkNode &&
        (index = list.dataModel.findIndexById(bookmarkNode.id)) != -1) {
      var sm = list.selectionModel;
      sm.anchorIndex = sm.leadIndex = sm.selectedIndex = index;
      list.scrollIndexIntoView(index);
    }
    list.removeEventListener('load', f);
  }
  list.addEventListener('load', f);
  var treeItem = bmm.treeLookup[parentId];
  treeItem.reveal();

  navigateTo(parentId);
}

var linkController;

/**
 * @return {!cr.LinkController} The link controller used to open links based on
 *     user clicks and keyboard actions.
 */
function getLinkController() {
  return linkController ||
      (linkController = new cr.LinkController(localStrings));
}

/**
 * Returns the selected bookmark nodes of the active element. Only call this
 * if the list or the tree is focused.
 * @return {!Array} Array of bookmark nodes.
 */
function getSelectedBookmarkNodes() {
  if (document.activeElement == list) {
    return list.selectedItems;
  } else if (document.activeElement == tree) {
    return [tree.selectedItem.bookmarkNode];
  } else {
    throw Error('getSelectedBookmarkNodes called when wrong element focused.');
  }
}

/**
 * @return {!Array.<string>} An array of the selected bookmark IDs.
 */
function getSelectedBookmarkIds() {
  return getSelectedBookmarkNodes().map(function(node) {
    return node.id;
  });
}

/**
 * Opens the selected bookmarks.
 * @param {LinkKind} kind The kind of link we want to open.
 */
function openBookmarks(kind) {
  // If we have selected any folders we need to find all items recursively.
  // We use multiple async calls to getSubtree instead of getting the whole
  // tree since we would like to minimize the amount of data sent.

  var urls = [];

  // Adds the node and all its children.
  function addNodes(node) {
    if (node.children) {
      node.children.forEach(function(child) {
        if (!bmm.isFolder(child))
          urls.push(child.url);
      });
    } else {
      urls.push(node.url);
    }
  }

  var nodes = getSelectedBookmarkNodes();

  // Get a future promise for every selected item.
  var promises = nodes.map(function(node) {
    if (bmm.isFolder(node))
      return bmm.loadSubtree(node.id);
    // Not a folder so we already have all the data we need.
    return new Promise(node.url);
  });

  var p = Promise.all.apply(null, promises);
  p.addListener(function(values) {
    values.forEach(function(v) {
      if (typeof v == 'string')
        urls.push(v);
      else
        addNodes(v);
    });
    getLinkController().openUrls(urls, kind);
  });
}

/**
 * Opens an item in the list.
 */
function openItem() {
  var bookmarkNodes = getSelectedBookmarkNodes();
  // If we double clicked or pressed enter on a single folder navigate to it.
  if (bookmarkNodes.length == 1 && bmm.isFolder(bookmarkNodes[0])) {
    navigateTo(bookmarkNodes[0].id);
  } else {
    openBookmarks(LinkKind.FOREGROUND_TAB);
  }
}

/**
 * Deletes the selected bookmarks.
 */
function deleteBookmarks() {
  getSelectedBookmarkIds().forEach(function(id) {
    chrome.bookmarks.removeTree(id);
  });
}

/**
 * Callback for the new folder command. This creates a new folder and starts
 * a rename of it.
 */
function newFolder() {
  var parentId = list.parentId;
  var isTree = document.activeElement == tree;
  chrome.bookmarks.create({
    title: localStrings.getString('new_folder_name'),
    parentId: parentId
  }, function(newNode) {
    // This callback happens before the event that triggers the tree/list to
    // get updated so delay the work so that the tree/list gets updated first.
    setTimeout(function() {
      var newItem;
      if (isTree) {
        newItem = bmm.treeLookup[newNode.id];
        tree.selectedItem = newItem;
        newItem.editing = true;
      } else {
        var index = list.dataModel.findIndexById(newNode.id);
        var sm = list.selectionModel;
        sm.anchorIndex = sm.leadIndex = sm.selectedIndex = index;
        scrollIntoViewAndMakeEditable(index);
      }
    }, 50);
  });
}

/**
 * Scrolls the list item into view and makes it editable.
 * @param {number} index The index of the item to make editable.
 */
function scrollIntoViewAndMakeEditable(index) {
  list.scrollIndexIntoView(index);
  // onscroll is now dispatched asynchronously so we have to postpone
  // the rest.
  setTimeout(function() {
    var item = list.getListItemByIndex(index);
    if (item)
      item.editing = true;
  });
}

/**
 * Adds a page to the current folder. This is called by the
 * add-new-bookmark-command handler.
 */
function addPage() {
  var parentId = list.parentId;
  var fakeNode = {
    title: '',
    url: '',
    parentId: parentId,
    id: 'new'
  };

  var dataModel = list.dataModel;
  var length = dataModel.length;
  dataModel.splice(length, 0, fakeNode);
  var sm = list.selectionModel;
  sm.anchorIndex = sm.leadIndex = sm.selectedIndex = length;
  scrollIntoViewAndMakeEditable(length);
}

/**
 * This function is used to select items after a user action such as paste, drop
 * add page etc.
 * @param {BookmarkList|BookmarkTree} target The target of the user action.
 * @param {=string} opt_selectedTreeId If provided, then select that tree id.
 */
function selectItemsAfterUserAction(target, opt_selectedTreeId) {
  // We get one onCreated event per item so we delay the handling until we got
  // no more events coming.

  var ids = [];
  var timer;

  function handle(id, bookmarkNode) {
    clearTimeout(timer);
    if (opt_selectedTreeId || list.parentId == bookmarkNode.parentId)
      ids.push(id);
    timer = setTimeout(handleTimeout, 50);
  }

  function handleTimeout() {
    chrome.bookmarks.onCreated.removeListener(handle);
    chrome.bookmarks.onMoved.removeListener(handle);

    if (opt_selectedTreeId && ids.indexOf(opt_selectedTreeId) != -1) {
      var index = ids.indexOf(opt_selectedTreeId);
      if (index != -1 && opt_selectedTreeId in bmm.treeLookup) {
        tree.selectedItem = bmm.treeLookup[opt_selectedTreeId];
      }
    } else if (target == list) {
      var dataModel = list.dataModel;
      var firstIndex = dataModel.findIndexById(ids[0]);
      var lastIndex = dataModel.findIndexById(ids[ids.length - 1]);
      if (firstIndex != -1 && lastIndex != -1) {
        var selectionModel = list.selectionModel;
        selectionModel.selectedIndex = -1;
        selectionModel.selectRange(firstIndex, lastIndex);
        selectionModel.anchorIndex = selectionModel.leadIndex = lastIndex;
        list.focus();
      }
    }

    list.endBatchUpdates();
  }

  list.startBatchUpdates();

  chrome.bookmarks.onCreated.addListener(handle);
  chrome.bookmarks.onMoved.addListener(handle);
  timer = setTimeout(handleTimeout, 300);
}

/**
 * Handler for the command event. This is used both for the tree and the list.
 * @param {!Event} e The event object.
 */
function handleCommand(e) {
  var command = e.command;
  var commandId = command.id;
  switch (commandId) {
    case 'show-in-folder-command':
      showInFolder();
      break;
    case 'open-in-new-tab-command':
      openBookmarks(LinkKind.FOREGROUND_TAB);
      break;
    case 'open-in-background-tab-command':
      openBookmarks(LinkKind.BACKGROUND_TAB);
      break;
    case 'open-in-new-window-command':
      openBookmarks(LinkKind.WINDOW);
      break;
    case 'open-incognito-window-command':
      openBookmarks(LinkKind.INCOGNITO);
      break;
    case 'delete-command':
      deleteBookmarks();
      break;
    case 'copy-command':
      chrome.experimental.bookmarkManager.copy(getSelectedBookmarkIds(),
                                               updatePasteCommand);
      break;
    case 'cut-command':
      chrome.experimental.bookmarkManager.cut(getSelectedBookmarkIds(),
                                              updatePasteCommand);
      break;
    case 'paste-command':
      selectItemsAfterUserAction(list);
      chrome.experimental.bookmarkManager.paste(list.parentId,
                                                getSelectedBookmarkIds());
      break;
    case 'sort-command':
      chrome.experimental.bookmarkManager.sortChildren(list.parentId);
      break;
    case 'rename-folder-command':
    case 'edit-command':
      if (document.activeElement == list) {
        var li = list.getListItem(list.selectedItem);
        if (li)
          li.editing = true;
      } else {
        document.activeElement.selectedItem.editing = true;
      }
      break;
    case 'new-folder-command':
      newFolder();
      break;
    case 'add-new-bookmark-command':
      addPage();
      break;
    case 'open-in-same-window-command':
      openItem();
      break;
  }
}

// Delete on all platforms. On Mac we also allow Meta+Backspace.
$('delete-command').shortcut = 'U+007F' +
                               (cr.isMac ? ' U+0008 Meta-U+0008' : '');

$('open-in-same-window-command').shortcut = cr.isMac ? 'Meta-Down' :
                                                       'Enter';

$('open-in-new-window-command').shortcut = 'Shift-Enter';
$('open-in-background-tab-command').shortcut = cr.isMac ? 'Meta-Enter' :
                                                          'Ctrl-Enter';
$('open-in-new-tab-command').shortcut = cr.isMac ? 'Shift-Meta-Enter' :
                                                   'Shift-Ctrl-Enter';

$('rename-folder-command').shortcut = $('edit-command').shortcut =
    cr.isMac ? 'Enter' : 'F2';

list.addEventListener('command', handleCommand);
tree.addEventListener('command', handleCommand);

// Execute the copy, cut and paste commands when those events are dispatched by
// the browser. This allows us to rely on the browser to handle the keyboard
// shortcuts for these commands.
(function() {
  function handle(id) {
    return function(e) {
      var command = $(id);
      if (!command.disabled) {
        command.execute();
        if (e) e.preventDefault(); // Prevent the system beep
      }
    };
  }

  // Listen to copy, cut and paste events and execute the associated commands.
  document.addEventListener('copy', handle('copy-command'));
  document.addEventListener('cut', handle('cut-command'));

  var pasteHandler = handle('paste-command');
  document.addEventListener('paste', function(e) {
    // Paste is a bit special since we need to do an async call to see if we can
    // paste because the paste command might not be up to date.
    updatePasteCommand(pasteHandler);
  });
})();