Javascript  |  665行  |  17.18 KB

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

cr.define('cr.ui', function() {
  // require cr.ui.define
  // require cr.ui.limitInputWidth

  /**
   * The number of pixels to indent per level.
   * @type {number}
   */
  const INDENT = 20;

  /**
   * Returns the computed style for an element.
   * @param {!Element} el The element to get the computed style for.
   * @return {!CSSStyleDeclaration} The computed style.
   */
  function getComputedStyle(el) {
    return el.ownerDocument.defaultView.getComputedStyle(el);
  }

  /**
   * Helper function that finds the first ancestor tree item.
   * @param {!Element} el The element to start searching from.
   * @return {cr.ui.TreeItem} The found tree item or null if not found.
   */
  function findTreeItem(el) {
    while (el && !(el instanceof TreeItem)) {
      el = el.parentNode;
    }
    return el;
  }

  /**
   * Creates a new tree element.
   * @param {Object=} opt_propertyBag Optional properties.
   * @constructor
   * @extends {HTMLElement}
   */
  var Tree = cr.ui.define('tree');

  Tree.prototype = {
    __proto__: HTMLElement.prototype,

    /**
     * Initializes the element.
     */
    decorate: function() {
      // Make list focusable
      if (!this.hasAttribute('tabindex'))
        this.tabIndex = 0;

      this.addEventListener('click', this.handleClick);
      this.addEventListener('mousedown', this.handleMouseDown);
      this.addEventListener('dblclick', this.handleDblClick);
      this.addEventListener('keydown', this.handleKeyDown);
    },

    /**
     * Returns the tree item that are children of this tree.
     */
    get items() {
      return this.children;
    },

    /**
     * Adds a tree item to the tree.
     * @param {!cr.ui.TreeItem} treeItem The item to add.
     */
    add: function(treeItem) {
      this.addAt(treeItem, 0xffffffff);
    },

    /**
     * Adds a tree item at the given index.
     * @param {!cr.ui.TreeItem} treeItem The item to add.
     * @param {number} index The index where we want to add the item.
     */
    addAt: function(treeItem, index) {
      this.insertBefore(treeItem, this.children[index]);
      treeItem.setDepth_(this.depth + 1);
    },

    /**
     * Removes a tree item child.
     * @param {!cr.ui.TreeItem} treeItem The tree item to remove.
     */
    remove: function(treeItem) {
      this.removeChild(treeItem);
    },

    /**
     * The depth of the node. This is 0 for the tree itself.
     * @type {number}
     */
    get depth() {
      return 0;
    },

    /**
     * Handles click events on the tree and forwards the event to the relevant
     * tree items as necesary.
     * @param {Event} e The click event object.
     */
    handleClick: function(e) {
      var treeItem = findTreeItem(e.target);
      if (treeItem)
        treeItem.handleClick(e);
    },

    handleMouseDown: function(e) {
      if (e.button == 2) // right
        this.handleClick(e);
    },

    /**
     * Handles double click events on the tree.
     * @param {Event} e The dblclick event object.
     */
    handleDblClick: function(e) {
      var treeItem = findTreeItem(e.target);
      if (treeItem)
        treeItem.expanded = !treeItem.expanded;
    },

    /**
     * Handles keydown events on the tree and updates selection and exanding
     * of tree items.
     * @param {Event} e The click event object.
     */
    handleKeyDown: function(e) {
      var itemToSelect;
      if (e.ctrlKey)
        return;

      var item = this.selectedItem;

      var rtl = getComputedStyle(item).direction == 'rtl';

      switch (e.keyIdentifier) {
        case 'Up':
          itemToSelect = item ? getPrevious(item) :
              this.items[this.items.length - 1];
          break;
        case 'Down':
          itemToSelect = item ? getNext(item) :
              this.items[0];
          break;
        case 'Left':
        case 'Right':
          // Don't let back/forward keyboard shortcuts be used.
          if (!cr.isMac && e.altKey || cr.isMac && e.metaKey)
            break;

          if (e.keyIdentifier == 'Left' && !rtl ||
              e.keyIdentifier == 'Right' && rtl) {
            if (item.expanded)
              item.expanded = false;
            else
              itemToSelect = findTreeItem(item.parentNode);
          } else {
            if (!item.expanded)
              item.expanded = true;
            else
              itemToSelect = item.items[0];
          }
          break;
        case 'Home':
          itemToSelect = this.items[0];
          break;
        case 'End':
          itemToSelect = this.items[this.items.length - 1];
          break;
      }

      if (itemToSelect) {
        itemToSelect.selected = true;
        e.preventDefault();
      }
    },

    /**
     * The selected tree item or null if none.
     * @type {cr.ui.TreeItem}
     */
    get selectedItem() {
      return this.selectedItem_ || null;
    },
    set selectedItem(item) {
      var oldSelectedItem = this.selectedItem_;
      if (oldSelectedItem != item) {
        // Set the selectedItem_ before deselecting the old item since we only
        // want one change when moving between items.
        this.selectedItem_ = item;

        if (oldSelectedItem)
          oldSelectedItem.selected = false;

        if (item)
          item.selected = true;

        cr.dispatchSimpleEvent(this, 'change');
      }
    },

    /**
     * @return {!ClientRect} The rect to use for the context menu.
     */
    getRectForContextMenu: function() {
      // TODO(arv): Add trait support so we can share more code between trees
      // and lists.
      if (this.selectedItem)
        return this.selectedItem.rowElement.getBoundingClientRect();
      return this.getBoundingClientRect();
    }
  };

  /**
   * This is used as a blueprint for new tree item elements.
   * @type {!HTMLElement}
   */
  var treeItemProto = (function() {
    var treeItem = cr.doc.createElement('div');
    treeItem.className = 'tree-item';
    treeItem.innerHTML = '<div class=tree-row>' +
        '<span class=expand-icon></span>' +
        '<span class=tree-label></span>' +
        '</div>' +
        '<div class=tree-children></div>';
    return treeItem;
  })();

  /**
   * Creates a new tree item.
   * @param {Object=} opt_propertyBag Optional properties.
   * @constructor
   * @extends {HTMLElement}
   */
  var TreeItem = cr.ui.define(function() {
    return treeItemProto.cloneNode(true);
  });

  TreeItem.prototype = {
    __proto__: HTMLElement.prototype,

    /**
     * Initializes the element.
     */
    decorate: function() {

    },

    /**
     * The tree items children.
     */
    get items() {
      return this.lastElementChild.children;
    },

    /**
     * The depth of the tree item.
     * @type {number}
     */
    depth_: 0,
    get depth() {
      return this.depth_;
    },

    /**
     * Sets the depth.
     * @param {number} depth The new depth.
     * @private
     */
    setDepth_: function(depth) {
      if (depth != this.depth_) {
        this.rowElement.style.WebkitPaddingStart = Math.max(0, depth - 1) *
            INDENT + 'px';
        this.depth_ = depth;
        var items = this.items;
        for (var i = 0, item; item = items[i]; i++) {
          item.setDepth_(depth + 1);
        }
      }
    },

    /**
     * Adds a tree item as a child.
     * @param {!cr.ui.TreeItem} child The child to add.
     */
    add: function(child) {
      this.addAt(child, 0xffffffff);
    },

    /**
     * Adds a tree item as a child at a given index.
     * @param {!cr.ui.TreeItem} child The child to add.
     * @param {number} index The index where to add the child.
     */
    addAt: function(child, index) {
      this.lastElementChild.insertBefore(child, this.items[index]);
      if (this.items.length == 1)
        this.hasChildren = true;
      child.setDepth_(this.depth + 1);
    },

    /**
     * Removes a child.
     * @param {!cr.ui.TreeItem} child The tree item child to remove.
     */
    remove: function(child) {
      // If we removed the selected item we should become selected.
      var tree = this.tree;
      var selectedItem = tree.selectedItem;
      if (selectedItem && child.contains(selectedItem))
        this.selected = true;

      this.lastElementChild.removeChild(child);
      if (this.items.length == 0)
        this.hasChildren = false;
    },

    /**
     * The parent tree item.
     * @type {!cr.ui.Tree|cr.ui.TreeItem}
     */
    get parentItem() {
      var p = this.parentNode;
      while (p && !(p instanceof TreeItem) && !(p instanceof Tree)) {
        p = p.parentNode;
      }
      return p;
    },

    /**
     * The tree that the tree item belongs to or null of no added to a tree.
     * @type {cr.ui.Tree}
     */
    get tree() {
      var t = this.parentItem;
      while (t && !(t instanceof Tree)) {
        t = t.parentItem;
      }
      return t;
    },

    /**
     * Whether the tree item is expanded or not.
     * @type {boolean}
     */
    get expanded() {
      return this.hasAttribute('expanded');
    },
    set expanded(b) {
      if (this.expanded == b)
        return;

      var treeChildren = this.lastElementChild;

      if (b) {
        if (this.mayHaveChildren_) {
          this.setAttribute('expanded', '');
          treeChildren.setAttribute('expanded', '');
          cr.dispatchSimpleEvent(this, 'expand', true);
          this.scrollIntoViewIfNeeded(false);
        }
      } else {
        var tree = this.tree;
        if (tree && !this.selected) {
          var oldSelected = tree.selectedItem;
          if (oldSelected && this.contains(oldSelected))
            this.selected = true;
        }
        this.removeAttribute('expanded');
        treeChildren.removeAttribute('expanded');
        cr.dispatchSimpleEvent(this, 'collapse', true);
      }
    },

    /**
     * Expands all parent items.
     */
    reveal: function() {
      var pi = this.parentItem;
      while (pi && !(pi instanceof Tree)) {
        pi.expanded = true;
        pi = pi.parentItem;
      }
    },

    /**
     * The element representing the row that gets highlighted.
     * @type {!HTMLElement}
     */
    get rowElement() {
      return this.firstElementChild;
    },

    /**
     * The element containing the label text and the icon.
     * @type {!HTMLElement}
     */
    get labelElement() {
      return this.firstElementChild.lastElementChild;
    },

    /**
     * The label text.
     * @type {string}
     */
    get label() {
      return this.labelElement.textContent;
    },
    set label(s) {
      this.labelElement.textContent = s;
    },

    /**
     * The URL for the icon.
     * @type {string}
     */
    get icon() {
      return getComputedStyle(this.labelElement).backgroundImage.slice(4, -1);
    },
    set icon(icon) {
      return this.labelElement.style.backgroundImage = url(icon);
    },

    /**
     * Whether the tree item is selected or not.
     * @type {boolean}
     */
    get selected() {
      return this.hasAttribute('selected');
    },
    set selected(b) {
      if (this.selected == b)
        return;
      var rowItem = this.firstElementChild;
      var tree = this.tree;
      if (b) {
        this.setAttribute('selected', '');
        rowItem.setAttribute('selected', '');
        this.reveal();
        this.labelElement.scrollIntoViewIfNeeded(false);
        if (tree)
          tree.selectedItem = this;
      } else {
        this.removeAttribute('selected');
        rowItem.removeAttribute('selected');
        if (tree && tree.selectedItem == this)
          tree.selectedItem = null;
      }
    },

    /**
     * Whether the tree item has children.
     * @type {boolean}
     */
    get mayHaveChildren_() {
      return this.hasAttribute('may-have-children');
    },
    set mayHaveChildren_(b) {
      var rowItem = this.firstElementChild;
      if (b) {
        this.setAttribute('may-have-children', '');
        rowItem.setAttribute('may-have-children', '');
      } else {
        this.removeAttribute('may-have-children');
        rowItem.removeAttribute('may-have-children');
      }
    },

    /**
     * Whether the tree item has children.
     * @type {boolean}
     */
    get hasChildren() {
      return !!this.items[0];
    },

    /**
     * Whether the tree item has children.
     * @type {boolean}
     */
    set hasChildren(b) {
      var rowItem = this.firstElementChild;
      this.setAttribute('has-children', b);
      rowItem.setAttribute('has-children', b);
      if (b)
        this.mayHaveChildren_ = true;
    },

    /**
     * Called when the user clicks on a tree item. This is forwarded from the
     * cr.ui.Tree.
     * @param {Event} e The click event.
     */
    handleClick: function(e) {
      if (e.target.className == 'expand-icon')
        this.expanded = !this.expanded;
      else
        this.selected = true;
    },

    /**
     * Makes the tree item user editable. If the user renamed the item a
     * bubbling {@code rename} event is fired.
     * @type {boolean}
     */
    set editing(editing) {
      var oldEditing = this.editing;
      if (editing == oldEditing)
        return;

      var self = this;
      var labelEl = this.labelElement;
      var text = this.label;
      var input;

      // 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 tree.focus blurs the input which will make the tree item
        // non editable.
        switch (e.keyIdentifier) {
          case 'U+001B':  // Esc
            input.value = text;
            // fall through
          case 'Enter':
            self.tree.focus();
        }
      }

      function stopPropagation(e) {
        e.stopPropagation();
      }

      if (editing) {
        this.selected = true;
        this.setAttribute('editing', '');
        this.draggable = false;

        // We create an input[type=text] and copy over the label value. When
        // the input loses focus we set editing to false again.
        input = this.ownerDocument.createElement('input');
        input.value = text;
        if (labelEl.firstChild)
          labelEl.replaceChild(input, labelEl.firstChild);
        else
          labelEl.appendChild(input);

        input.addEventListener('keydown', handleKeydown);
        input.addEventListener('blur', (function() {
          this.editing = false;
        }).bind(this));

        // Make sure that double clicks do not expand and collapse the tree
        // item.
        var eventsToStop = ['mousedown', 'mouseup', 'contextmenu', 'dblclick'];
        eventsToStop.forEach(function(type) {
          input.addEventListener(type, stopPropagation);
        });

        // Wait for the input element to recieve focus before sizing it.
        var rowElement = this.rowElement;
        function onFocus() {
          input.removeEventListener('focus', onFocus);
          // 20 = the padding and border of the tree-row
          cr.ui.limitInputWidth(input, rowElement, 20);
        }
        input.addEventListener('focus', onFocus);
        input.focus();
        input.select();

        this.oldLabel_ = text;
      } else {
        this.removeAttribute('editing');
        this.draggable = true;
        input = labelEl.firstChild;
        var value = input.value;
        if (/^\s*$/.test(value)) {
          labelEl.textContent = this.oldLabel_;
        } else {
          labelEl.textContent = value;
          if (value != this.oldLabel_) {
            cr.dispatchSimpleEvent(this, 'rename', true);
          }
        }
        delete this.oldLabel_;
      }
    },

    get editing() {
      return this.hasAttribute('editing');
    }
  };

  /**
   * Helper function that returns the next visible tree item.
   * @param {cr.ui.TreeItem} item The tree item.
   * @retrun {cr.ui.TreeItem} The found item or null.
   */
  function getNext(item) {
    if (item.expanded) {
      var firstChild = item.items[0];
      if (firstChild) {
        return firstChild;
      }
    }

    return getNextHelper(item);
  }

  /**
   * Another helper function that returns the next visible tree item.
   * @param {cr.ui.TreeItem} item The tree item.
   * @retrun {cr.ui.TreeItem} The found item or null.
   */
  function getNextHelper(item) {
    if (!item)
      return null;

    var nextSibling = item.nextElementSibling;
    if (nextSibling) {
      return nextSibling;
    }
    return getNextHelper(item.parentItem);
  }

  /**
   * Helper function that returns the previous visible tree item.
   * @param {cr.ui.TreeItem} item The tree item.
   * @retrun {cr.ui.TreeItem} The found item or null.
   */
  function getPrevious(item) {
    var previousSibling = item.previousElementSibling;
    return previousSibling ? getLastHelper(previousSibling) : item.parentItem;
  }

  /**
   * Helper function that returns the last visible tree item in the subtree.
   * @param {cr.ui.TreeItem} item The item to find the last visible item for.
   * @return {cr.ui.TreeItem} The found item or null.
   */
  function getLastHelper(item) {
    if (!item)
      return null;
    if (item.expanded && item.hasChildren) {
      var lastChild = item.items[item.items.length - 1];
      return getLastHelper(lastChild);
    }
    return item;
  }

  // Export
  return {
    Tree: Tree,
    TreeItem: TreeItem
  };
});