/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

const {Cc, Cu, Ci} = require("chrome");

// Page size for pageup/pagedown
const PAGE_SIZE = 10;

const PREVIEW_AREA = 700;
const DEFAULT_MAX_CHILDREN = 100;

let {UndoStack} = require("devtools/shared/undo");
let EventEmitter = require("devtools/shared/event-emitter");
let {editableField, InplaceEditor} = require("devtools/shared/inplace-editor");

Cu.import("resource:///modules/devtools/LayoutHelpers.jsm");
Cu.import("resource://gre/modules/devtools/Templater.jsm");
Cu.import("resource://gre/modules/Services.jsm");

/**
 * Vocabulary for the purposes of this file:
 *
 * MarkupContainer - the structure that holds an editor and its
 *  immediate children in the markup panel.
 * Node - A content node.
 * object.elt - A UI element in the markup panel.
 */

/**
 * The markup tree.  Manages the mapping of nodes to MarkupContainers,
 * updating based on mutations, and the undo/redo bindings.
 *
 * @param Inspector aInspector
 *        The inspector we're watching.
 * @param iframe aFrame
 *        An iframe in which the caller has kindly loaded markup-view.xhtml.
 */
function MarkupView(aInspector, aFrame, aControllerWindow)
{
  this._inspector = aInspector;
  this._frame = aFrame;
  this.doc = this._frame.contentDocument;
  this._elt = this.doc.querySelector("#root");

  try {
    this.maxChildren = Services.prefs.getIntPref("devtools.markup.pagesize");
  } catch(ex) {
    this.maxChildren = DEFAULT_MAX_CHILDREN;
  }

  this.undo = new UndoStack();
  this.undo.installController(aControllerWindow);

  this._containers = new WeakMap();

  this._observer = new this.doc.defaultView.MutationObserver(this._mutationObserver.bind(this));

  this._boundOnNewSelection = this._onNewSelection.bind(this);
  this._inspector.selection.on("new-node", this._boundOnNewSelection);
  this._onNewSelection();

  this._boundKeyDown = this._onKeyDown.bind(this);
  this._frame.contentWindow.addEventListener("keydown", this._boundKeyDown, false);

  this._boundFocus = this._onFocus.bind(this);
  this._frame.addEventListener("focus", this._boundFocus, false);

  this._initPreview();
}

exports.MarkupView = MarkupView;

MarkupView.prototype = {
  _selectedContainer: null,

  template: function MT_template(aName, aDest, aOptions={stack: "markup-view.xhtml"})
  {
    let node = this.doc.getElementById("template-" + aName).cloneNode(true);
    node.removeAttribute("id");
    template(node, aDest, aOptions);
    return node;
  },

  /**
   * Get the MarkupContainer object for a given node, or undefined if
   * none exists.
   */
  getContainer: function MT_getContainer(aNode)
  {
    return this._containers.get(aNode);
  },

  /**
   * Highlight the inspector selected node.
   */
  _onNewSelection: function MT__onNewSelection()
  {
    if (this._inspector.selection.isNode()) {
      this.showNode(this._inspector.selection.node, true);
      this.markNodeAsSelected(this._inspector.selection.node);
    } else {
      this.unmarkSelectedNode();
    }
  },

  /**
   * Create a TreeWalker to find the next/previous
   * node for selection.
   */
  _selectionWalker: function MT__seletionWalker(aStart)
  {
    let walker = this.doc.createTreeWalker(
      aStart || this._elt,
      Ci.nsIDOMNodeFilter.SHOW_ELEMENT,
      function(aElement) {
        if (aElement.container && aElement.container.visible) {
          return Ci.nsIDOMNodeFilter.FILTER_ACCEPT;
        }
        return Ci.nsIDOMNodeFilter.FILTER_SKIP;
      }
    );
    walker.currentNode = this._selectedContainer.elt;
    return walker;
  },

  /**
   * Key handling.
   */
  _onKeyDown: function MT__KeyDown(aEvent)
  {
    let handled = true;

    // Ignore keystrokes that originated in editors.
    if (aEvent.target.tagName.toLowerCase() === "input" ||
        aEvent.target.tagName.toLowerCase() === "textarea") {
      return;
    }

    switch(aEvent.keyCode) {
      case Ci.nsIDOMKeyEvent.DOM_VK_DELETE:
      case Ci.nsIDOMKeyEvent.DOM_VK_BACK_SPACE:
        this.deleteNode(this._selectedContainer.node);
        break;
      case Ci.nsIDOMKeyEvent.DOM_VK_HOME:
        this.navigate(this._containers.get(this._rootNode.firstChild));
        break;
      case Ci.nsIDOMKeyEvent.DOM_VK_LEFT:
        if (this._selectedContainer.expanded) {
          this.collapseNode(this._selectedContainer.node);
        } else {
          let parent = this._selectionWalker().parentNode();
          if (parent) {
            this.navigate(parent.container);
          }
        }
        break;
      case Ci.nsIDOMKeyEvent.DOM_VK_RIGHT:
        if (!this._selectedContainer.expanded) {
          this.expandNode(this._selectedContainer.node);
        } else {
          let next = this._selectionWalker().nextNode();
          if (next) {
            this.navigate(next.container);
          }
        }
        break;
      case Ci.nsIDOMKeyEvent.DOM_VK_UP:
        let prev = this._selectionWalker().previousNode();
        if (prev) {
          this.navigate(prev.container);
        }
        break;
      case Ci.nsIDOMKeyEvent.DOM_VK_DOWN:
        let next = this._selectionWalker().nextNode();
        if (next) {
          this.navigate(next.container);
        }
        break;
      case Ci.nsIDOMKeyEvent.DOM_VK_PAGE_UP: {
        let walker = this._selectionWalker();
        let selection = this._selectedContainer;
        for (let i = 0; i < PAGE_SIZE; i++) {
          let prev = walker.previousNode();
          if (!prev) {
            break;
          }
          selection = prev.container;
        }
        this.navigate(selection);
        break;
      }
      case Ci.nsIDOMKeyEvent.DOM_VK_PAGE_DOWN: {
        let walker = this._selectionWalker();
        let selection = this._selectedContainer;
        for (let i = 0; i < PAGE_SIZE; i++) {
          let next = walker.nextNode();
          if (!next) {
            break;
          }
          selection = next.container;
        }
        this.navigate(selection);
        break;
      }
      default:
        handled = false;
    }
    if (handled) {
      aEvent.stopPropagation();
      aEvent.preventDefault();
    }
  },

  /**
   * Delete a node from the DOM.
   * This is an undoable action.
   */
  deleteNode: function MC__deleteNode(aNode)
  {
    let doc = nodeDocument(aNode);
    if (aNode === doc ||
        aNode === doc.documentElement ||
        aNode.nodeType == Ci.nsIDOMNode.DOCUMENT_TYPE_NODE) {
      return;
    }

    let parentNode = aNode.parentNode;
    let sibling = aNode.nextSibling;

    this.undo.do(function() {
      if (aNode.selected) {
        this.navigate(this._containers.get(parentNode));
      }
      parentNode.removeChild(aNode);
    }.bind(this), function() {
      parentNode.insertBefore(aNode, sibling);
    });
  },

  /**
   * If an editable item is focused, select its container.
   */
  _onFocus: function MC__onFocus(aEvent) {
    let parent = aEvent.target;
    while (!parent.container) {
      parent = parent.parentNode;
    }
    if (parent) {
      this.navigate(parent.container, true);
    }
  },

  /**
   * Handle a user-requested navigation to a given MarkupContainer,
   * updating the inspector's currently-selected node.
   *
   * @param MarkupContainer aContainer
   *        The container we're navigating to.
   * @param aIgnoreFocus aIgnoreFocus
   *        If falsy, keyboard focus will be moved to the container too.
   */
  navigate: function MT__navigate(aContainer, aIgnoreFocus)
  {
    if (!aContainer) {
      return;
    }

    let node = aContainer.node;
    this.showNode(node, false);

    this._inspector.selection.setNode(node, "treepanel");
    // This event won't be fired if the node is the same. But the highlighter
    // need to lock the node if it wasn't.
    this._inspector.selection.emit("new-node");

    if (!aIgnoreFocus) {
      aContainer.focus();
    }
  },

  /**
   * Make sure a node is included in the markup tool.
   *
   * @param DOMNode aNode
   *        The node in the content document.
   *
   * @returns MarkupContainer The MarkupContainer object for this element.
   */
  importNode: function MT_importNode(aNode, aExpand)
  {
    if (!aNode) {
      return null;
    }

    if (this._containers.has(aNode)) {
      return this._containers.get(aNode);
    }

    this._observer.observe(aNode, {
      attributes: true,
      childList: true,
      characterData: true,
    });

    let walker = documentWalker(aNode);
    let parent = walker.parentNode();
    if (parent) {
      var container = new MarkupContainer(this, aNode);
    } else {
      var container = new RootContainer(this, aNode);
      this._elt.appendChild(container.elt);
      this._rootNode = aNode;
      aNode.addEventListener("load", function MP_watch_contentLoaded(aEvent) {
        // Fake a childList mutation here.
        this._mutationObserver([{target: aEvent.target, type: "childList"}]);
      }.bind(this), true);
    }

    this._containers.set(aNode, container);
    // FIXME: set an expando to prevent the the wrapper from disappearing
    // See bug 819131 for details.
    aNode.__preserveHack = true;
    container.expanded = aExpand;

    container.childrenDirty = true;
    this._updateChildren(container);

    if (parent) {
      this.importNode(parent, true);
    }
    return container;
  },

  /**
   * Mutation observer used for included nodes.
   */
  _mutationObserver: function MT__mutationObserver(aMutations)
  {
    for (let mutation of aMutations) {
      let container = this._containers.get(mutation.target);
      if (!container) {
        // Container might not exist if this came from a load event for an iframe
        // we're not viewing.
        continue;
      }
      if (mutation.type === "attributes" || mutation.type === "characterData") {
        container.update();
      } else if (mutation.type === "childList") {
        container.childrenDirty = true;
        this._updateChildren(container);
      }
    }
    this._inspector.emit("markupmutation");
  },

  /**
   * Make sure the given node's parents are expanded and the
   * node is scrolled on to screen.
   */
  showNode: function MT_showNode(aNode, centered)
  {
    let container = this.importNode(aNode);
    this._updateChildren(container);
    let walker = documentWalker(aNode);
    let parent;
    while (parent = walker.parentNode()) {
      this._updateChildren(this.getContainer(parent));
      this.expandNode(parent);
    }
    LayoutHelpers.scrollIntoViewIfNeeded(this._containers.get(aNode).editor.elt, centered);
  },

  /**
   * Expand the container's children.
   */
  _expandContainer: function MT__expandContainer(aContainer)
  {
    if (aContainer.hasChildren && !aContainer.expanded) {
      aContainer.expanded = true;
      this._updateChildren(aContainer);
    }
  },

  /**
   * Expand the node's children.
   */
  expandNode: function MT_expandNode(aNode)
  {
    let container = this._containers.get(aNode);
    this._expandContainer(container);
  },

  /**
   * Expand the entire tree beneath a container.
   *
   * @param aContainer The container to expand.
   */
  _expandAll: function MT_expandAll(aContainer)
  {
    this._expandContainer(aContainer);
    let child = aContainer.children.firstChild;
    while (child) {
      this._expandAll(child.container);
      child = child.nextSibling;
    }
  },

  /**
   * Expand the entire tree beneath a node.
   *
   * @param aContainer The node to expand, or null
   *        to start from the top.
   */
  expandAll: function MT_expandAll(aNode)
  {
    aNode = aNode || this._rootNode;
    this._expandAll(this._containers.get(aNode));
  },

  /**
   * Collapse the node's children.
   */
  collapseNode: function MT_collapseNode(aNode)
  {
    let container = this._containers.get(aNode);
    container.expanded = false;
  },

  /**
   * Mark the given node selected.
   */
  markNodeAsSelected: function MT_markNodeAsSelected(aNode)
  {
    let container = this._containers.get(aNode);
    if (this._selectedContainer === container) {
      return false;
    }
    if (this._selectedContainer) {
      this._selectedContainer.selected = false;
    }
    this._selectedContainer = container;
    if (aNode) {
      this._selectedContainer.selected = true;
    }

    this._ensureSelectionVisible();

    return true;
  },

  /**
   * Make sure that every ancestor of the selection are updated
   * and included in the list of visible children.
   */
  _ensureSelectionVisible: function MT_ensureSelectionVisible()
  {
    let node = this._selectedContainer.node;
    let walker = documentWalker(node);
    while (node) {
      let container = this._containers.get(node);
      let parent = walker.parentNode();
      if (!container.elt.parentNode) {
        let parentContainer = this._containers.get(parent);
        parentContainer.childrenDirty = true;
        this._updateChildren(parentContainer, node);
      }

      node = parent;
    }
  },

  /**
   * Unmark selected node (no node selected).
   */
  unmarkSelectedNode: function MT_unmarkSelectedNode()
  {
    if (this._selectedContainer) {
      this._selectedContainer.selected = false;
      this._selectedContainer = null;
    }
  },

  /**
   * Called when the markup panel initiates a change on a node.
   */
  nodeChanged: function MT_nodeChanged(aNode)
  {
    if (aNode === this._inspector.selection) {
      this._inspector.change("markupview");
    }
  },

  /**
   * Make sure all children of the given container's node are
   * imported and attached to the container in the right order.
   * @param aCentered If provided, this child will be included
   *        in the visible subset, and will be roughly centered
   *        in that list.
   */
  _updateChildren: function MT__updateChildren(aContainer, aCentered)
  {
    if (!aContainer.childrenDirty) {
      return false;
    }

    // Get a tree walker pointing at the first child of the node.
    let treeWalker = documentWalker(aContainer.node);
    let child = treeWalker.firstChild();
    aContainer.hasChildren = !!child;

    if (!aContainer.expanded) {
      return;
    }

    aContainer.childrenDirty = false;

    let children = this._getVisibleChildren(aContainer, aCentered);
    let fragment = this.doc.createDocumentFragment();

    for (child of children.children) {
      let container = this.importNode(child, false);
      fragment.appendChild(container.elt);
    }

    while (aContainer.children.firstChild) {
      aContainer.children.removeChild(aContainer.children.firstChild);
    }

    if (!(children.hasFirst && children.hasLast)) {
      let data = {
        showing: this.strings.GetStringFromName("markupView.more.showing"),
        showAll: this.strings.formatStringFromName(
                  "markupView.more.showAll",
                  [aContainer.node.children.length.toString()], 1),
        allButtonClick: function() {
          aContainer.maxChildren = -1;
          aContainer.childrenDirty = true;
          this._updateChildren(aContainer);
        }.bind(this)
      };

      if (!children.hasFirst) {
        let span = this.template("more-nodes", data);
        fragment.insertBefore(span, fragment.firstChild);
      }
      if (!children.hasLast) {
        let span = this.template("more-nodes", data);
        fragment.appendChild(span);
      }
    }

    aContainer.children.appendChild(fragment);

    return true;
  },

  /**
   * Return a list of the children to display for this container.
   */
  _getVisibleChildren: function MV__getVisibleChildren(aContainer, aCentered)
  {
    let maxChildren = aContainer.maxChildren || this.maxChildren;
    if (maxChildren == -1) {
      maxChildren = Number.MAX_VALUE;
    }
    let firstChild = documentWalker(aContainer.node).firstChild();
    let lastChild = documentWalker(aContainer.node).lastChild();

    if (!firstChild) {
      // No children, we're done.
      return { hasFirst: true, hasLast: true, children: [] };
    }

    // By default try to put the selected child in the middle of the list.
    let start = aCentered || firstChild;

    // Start by reading backward from the starting point....
    let nodes = [];
    let backwardWalker = documentWalker(start);
    if (backwardWalker.previousSibling()) {
      let backwardCount = Math.floor(maxChildren / 2);
      let backwardNodes = this._readBackward(backwardWalker, backwardCount);
      nodes = backwardNodes;
    }

    // Then read forward by any slack left in the max children...
    let forwardWalker = documentWalker(start);
    let forwardCount = maxChildren - nodes.length;
    nodes = nodes.concat(this._readForward(forwardWalker, forwardCount));

    // If there's any room left, it means we've run all the way to the end.
    // In that case, there might still be more items at the front.
    let remaining = maxChildren - nodes.length;
    if (remaining > 0 && nodes[0] != firstChild) {
      let firstNodes = this._readBackward(backwardWalker, remaining);

      // Then put it all back together.
      nodes = firstNodes.concat(nodes);
    }

    return {
      hasFirst: nodes[0] == firstChild,
      hasLast: nodes[nodes.length - 1] == lastChild,
      children: nodes
    };
  },

  _readForward: function MV__readForward(aWalker, aCount)
  {
    let ret = [];
    let node = aWalker.currentNode;
    do {
      ret.push(node);
      node = aWalker.nextSibling();
    } while (node && --aCount);
    return ret;
  },

  _readBackward: function MV__readBackward(aWalker, aCount)
  {
    let ret = [];
    let node = aWalker.currentNode;
    do {
      ret.push(node);
      node = aWalker.previousSibling();
    } while(node && --aCount);
    ret.reverse();
    return ret;
  },

  /**
   * Tear down the markup panel.
   */
  destroy: function MT_destroy()
  {
    this.undo.destroy();
    delete this.undo;

    this._frame.removeEventListener("focus", this._boundFocus, false);
    delete this._boundFocus;

    this._frame.contentWindow.removeEventListener("scroll", this._boundUpdatePreview, true);
    this._frame.contentWindow.removeEventListener("resize", this._boundResizePreview, true);
    this._frame.contentWindow.removeEventListener("overflow", this._boundResizePreview, true);
    this._frame.contentWindow.removeEventListener("underflow", this._boundResizePreview, true);
    delete this._boundUpdatePreview;

    this._frame.contentWindow.removeEventListener("keydown", this._boundKeyDown, true);
    delete this._boundKeyDown;

    this._inspector.selection.off("new-node", this._boundOnNewSelection);
    delete this._boundOnNewSelection;

    delete this._elt;

    delete this._containers;
    this._observer.disconnect();
    delete this._observer;
  },

  /**
   * Initialize the preview panel.
   */
  _initPreview: function MT_initPreview()
  {
    if (!Services.prefs.getBoolPref("devtools.inspector.markupPreview")) {
      return;
    }

    this._previewBar = this.doc.querySelector("#previewbar");
    this._preview = this.doc.querySelector("#preview");
    this._viewbox = this.doc.querySelector("#viewbox");

    this._previewBar.classList.remove("disabled");

    this._previewWidth = this._preview.getBoundingClientRect().width;

    this._boundResizePreview = this._resizePreview.bind(this);
    this._frame.contentWindow.addEventListener("resize", this._boundResizePreview, true);
    this._frame.contentWindow.addEventListener("overflow", this._boundResizePreview, true);
    this._frame.contentWindow.addEventListener("underflow", this._boundResizePreview, true);

    this._boundUpdatePreview = this._updatePreview.bind(this);
    this._frame.contentWindow.addEventListener("scroll", this._boundUpdatePreview, true);
    this._updatePreview();
  },


  /**
   * Move the preview viewbox.
   */
  _updatePreview: function MT_updatePreview()
  {
    let win = this._frame.contentWindow;

    if (win.scrollMaxY == 0) {
      this._previewBar.classList.add("disabled");
      return;
    }

    this._previewBar.classList.remove("disabled");

    let ratio = this._previewWidth / PREVIEW_AREA;
    let width = ratio * win.innerWidth;

    let height = ratio * (win.scrollMaxY + win.innerHeight);
    let scrollTo
    if (height >= win.innerHeight) {
      scrollTo = -(height - win.innerHeight) * (win.scrollY / win.scrollMaxY);
      this._previewBar.setAttribute("style", "height:" + height + "px;transform:translateY(" + scrollTo + "px)");
    } else {
      this._previewBar.setAttribute("style", "height:100%");
    }

    let bgSize = ~~width + "px " + ~~height + "px";
    this._preview.setAttribute("style", "background-size:" + bgSize);

    let height = ~~(win.innerHeight * ratio) + "px";
    let top = ~~(win.scrollY * ratio) + "px";
    this._viewbox.setAttribute("style", "height:" + height + ";transform: translateY(" + top + ")");
  },

  /**
   * Hide the preview while resizing, to avoid slowness.
   */
  _resizePreview: function MT_resizePreview()
  {
    let win = this._frame.contentWindow;
    this._previewBar.classList.add("hide");
    win.clearTimeout(this._resizePreviewTimeout);

    win.setTimeout(function() {
      this._updatePreview();
      this._previewBar.classList.remove("hide");
    }.bind(this), 1000);
  },

};


/**
 * The main structure for storing a document node in the markup
 * tree.  Manages creation of the editor for the node and
 * a <ul> for placing child elements, and expansion/collapsing
 * of the element.
 *
 * @param MarkupView aMarkupView
 *        The markup view that owns this container.
 * @param DOMNode aNode
 *        The node to display.
 */
function MarkupContainer(aMarkupView, aNode)
{
  this.markup = aMarkupView;
  this.doc = this.markup.doc;
  this.undo = this.markup.undo;
  this.node = aNode;

  if (aNode.nodeType == Ci.nsIDOMNode.TEXT_NODE) {
    this.editor = new TextEditor(this, aNode, "text");
  } else if (aNode.nodeType == Ci.nsIDOMNode.COMMENT_NODE) {
    this.editor = new TextEditor(this, aNode, "comment");
  } else if (aNode.nodeType == Ci.nsIDOMNode.ELEMENT_NODE) {
    this.editor = new ElementEditor(this, aNode);
  } else if (aNode.nodeType == Ci.nsIDOMNode.DOCUMENT_TYPE_NODE) {
    this.editor = new DoctypeEditor(this, aNode);
  } else {
    this.editor = new GenericEditor(this.markup, aNode);
  }

  // The template will fill the following properties
  this.elt = null;
  this.expander = null;
  this.codeBox = null;
  this.children = null;
  this.markup.template("container", this);
  this.elt.container = this;

  this.expander.addEventListener("click", function() {
    this.markup.navigate(this);

    if (this.expanded) {
      this.markup.collapseNode(this.node);
    } else {
      this.markup.expandNode(this.node);
    }
  }.bind(this));

  this.codeBox.insertBefore(this.editor.elt, this.children);

  this.editor.elt.addEventListener("mousedown", function(evt) {
    this.markup.navigate(this);
  }.bind(this), false);

  if (this.editor.summaryElt) {
    this.editor.summaryElt.addEventListener("click", function(evt) {
      this.markup.navigate(this);
      this.markup.expandNode(this.node);
    }.bind(this), false);
    this.codeBox.appendChild(this.editor.summaryElt);
  }

  if (this.editor.closeElt) {
    this.editor.closeElt.addEventListener("mousedown", function(evt) {
      this.markup.navigate(this);
    }.bind(this), false);
    this.codeBox.appendChild(this.editor.closeElt);
  }

}

MarkupContainer.prototype = {
  /**
   * True if the current node has children.  The MarkupView
   * will set this attribute for the MarkupContainer.
   */
  _hasChildren: false,

  get hasChildren() {
    return this._hasChildren;
  },

  set hasChildren(aValue) {
    this._hasChildren = aValue;
    if (aValue) {
      this.expander.style.visibility = "visible";
    } else {
      this.expander.style.visibility = "hidden";
    }
  },

  /**
   * True if the node has been visually expanded in the tree.
   */
  get expanded() {
    return this.children.hasAttribute("expanded");
  },

  set expanded(aValue) {
    if (aValue) {
      this.expander.setAttribute("open", "");
      this.children.setAttribute("expanded", "");
      if (this.editor.summaryElt) {
        this.editor.summaryElt.setAttribute("expanded", "");
      }
    } else {
      this.expander.removeAttribute("open");
      this.children.removeAttribute("expanded");
      if (this.editor.summaryElt) {
        this.editor.summaryElt.removeAttribute("expanded");
      }
    }
  },

  /**
   * True if the container is visible in the markup tree.
   */
  get visible()
  {
    return this.elt.getBoundingClientRect().height > 0;
  },

  /**
   * True if the container is currently selected.
   */
  _selected: false,

  get selected() {
    return this._selected;
  },

  set selected(aValue) {
    this._selected = aValue;
    if (this._selected) {
      this.editor.elt.classList.add("theme-selected");
      if (this.editor.closeElt) {
        this.editor.closeElt.classList.add("theme-selected");
      }
    } else {
      this.editor.elt.classList.remove("theme-selected");
      if (this.editor.closeElt) {
        this.editor.closeElt.classList.remove("theme-selected");
      }
    }
  },

  /**
   * Update the container's editor to the current state of the
   * viewed node.
   */
  update: function MC_update()
  {
    if (this.editor.update) {
      this.editor.update();
    }
  },

  /**
   * Try to put keyboard focus on the current editor.
   */
  focus: function MC_focus()
  {
    let focusable = this.editor.elt.querySelector("[tabindex]");
    if (focusable) {
      focusable.focus();
    }
  },
}

/**
 * Dummy container node used for the root document element.
 */
function RootContainer(aMarkupView, aNode)
{
  this.doc = aMarkupView.doc;
  this.elt = this.doc.createElement("ul");
  this.children = this.elt;
  this.node = aNode;
}

/**
 * Creates an editor for simple nodes.
 */
function GenericEditor(aContainer, aNode)
{
  this.elt = aContainer.doc.createElement("span");
  this.elt.className = "editor";
  this.elt.textContent = aNode.nodeName;
}

/**
 * Creates an editor for a DOCTYPE node.
 *
 * @param MarkupContainer aContainer The container owning this editor.
 * @param DOMNode aNode The node being edited.
 */
function DoctypeEditor(aContainer, aNode)
{
  this.elt = aContainer.doc.createElement("span");
  this.elt.className = "editor comment";
  this.elt.textContent = '<!DOCTYPE ' + aNode.name +
     (aNode.publicId ? ' PUBLIC "' +  aNode.publicId + '"': '') +
     (aNode.systemId ? ' "' + aNode.systemId + '"' : '') +
     '>';
}

/**
 * Creates a simple text editor node, used for TEXT and COMMENT
 * nodes.
 *
 * @param MarkupContainer aContainer The container owning this editor.
 * @param DOMNode aNode The node being edited.
 * @param string aTemplate The template id to use to build the editor.
 */
function TextEditor(aContainer, aNode, aTemplate)
{
  this.node = aNode;

  aContainer.markup.template(aTemplate, this);

  editableField({
    element: this.value,
    stopOnReturn: true,
    trigger: "dblclick",
    multiline: true,
    done: function TE_done(aVal, aCommit) {
      if (!aCommit) {
        return;
      }
      let oldValue = this.node.nodeValue;
      aContainer.undo.do(function() {
        this.node.nodeValue = aVal;
        aContainer.markup.nodeChanged(this.node);
      }.bind(this), function() {
        this.node.nodeValue = oldValue;
        aContainer.markup.nodeChanged(this.node);
      }.bind(this));
    }.bind(this)
  });

  this.update();
}

TextEditor.prototype = {
  update: function TE_update()
  {
    this.value.textContent = this.node.nodeValue;
  }
};

/**
 * Creates an editor for an Element node.
 *
 * @param MarkupContainer aContainer The container owning this editor.
 * @param Element aNode The node being edited.
 */
function ElementEditor(aContainer, aNode)
{
  this.doc = aContainer.doc;
  this.undo = aContainer.undo;
  this.template = aContainer.markup.template.bind(aContainer.markup);
  this.container = aContainer;
  this.markup = this.container.markup;
  this.node = aNode;

  this.attrs = [];

  // The templates will fill the following properties
  this.elt = null;
  this.tag = null;
  this.attrList = null;
  this.newAttr = null;
  this.summaryElt = null;
  this.closeElt = null;

  // Create the main editor
  this.template("element", this);

  if (this.node.firstChild || this.node.textContent.length > 0) {
    // Create the summary placeholder
    this.template("elementContentSummary", this);
  }

  // Create the closing tag
  this.template("elementClose", this);

  // Make the tag name editable (unless this is a document element)
  if (aNode != aNode.ownerDocument.documentElement) {
    this.tag.setAttribute("tabindex", "0");
    editableField({
      element: this.tag,
      trigger: "dblclick",
      stopOnReturn: true,
      done: this.onTagEdit.bind(this),
    });
  }

  // Make the new attribute space editable.
  editableField({
    element: this.newAttr,
    trigger: "dblclick",
    stopOnReturn: true,
    done: function EE_onNew(aVal, aCommit) {
      if (!aCommit) {
        return;
      }

      try {
        this._applyAttributes(aVal);
      } catch (x) {
        return;
      }
    }.bind(this)
  });

  let tagName = this.node.nodeName.toLowerCase();
  this.tag.textContent = tagName;
  this.closeTag.textContent = tagName;

  this.update();
}

ElementEditor.prototype = {
  /**
   * Update the state of the editor from the node.
   */
  update: function EE_update()
  {
    let attrs = this.node.attributes;
    if (!attrs) {
      return;
    }

    // Hide all the attribute editors, they'll be re-shown if they're
    // still applicable.  Don't update attributes that are being
    // actively edited.
    let attrEditors = this.attrList.querySelectorAll(".attreditor");
    for (let i = 0; i < attrEditors.length; i++) {
      if (!attrEditors[i].inplaceEditor) {
        attrEditors[i].style.display = "none";
      }
    }

    // Get the attribute editor for each attribute that exists on
    // the node and show it.
    for (let i = 0; i < attrs.length; i++) {
      let attr = this._createAttribute(attrs[i]);
      if (!attr.inplaceEditor) {
        attr.style.removeProperty("display");
      }
    }
  },

  _createAttribute: function EE_createAttribute(aAttr, aBefore)
  {
    if (this.attrs.indexOf(aAttr.name) !== -1) {
      var attr = this.attrs[aAttr.name];
      var name = attr.querySelector(".attrname");
      var val = attr.querySelector(".attrvalue");
    } else {
      // Create the template editor, which will save some variables here.
      let data = {
        attrName: aAttr.name,
      };
      this.template("attribute", data);
      var {attr, inner, name, val} = data;

      // Figure out where we should place the attribute.
      let before = aBefore || null;
      if (aAttr.name == "id") {
        before = this.attrList.firstChild;
      } else if (aAttr.name == "class") {
        let idNode = this.attrs["id"];
        before = idNode ? idNode.nextSibling : this.attrList.firstChild;
      }
      this.attrList.insertBefore(attr, before);

      // Make the attribute editable.
      editableField({
        element: inner,
        trigger: "dblclick",
        stopOnReturn: true,
        selectAll: false,
        start: function EE_editAttribute_start(aEditor, aEvent) {
          // If the editing was started inside the name or value areas,
          // select accordingly.
          if (aEvent && aEvent.target === name) {
            aEditor.input.setSelectionRange(0, name.textContent.length);
          } else if (aEvent && aEvent.target === val) {
            let length = val.textContent.length;
            let editorLength = aEditor.input.value.length;
            let start = editorLength - (length + 1);
            aEditor.input.setSelectionRange(start, start + length);
          } else {
            aEditor.input.select();
          }
        },
        done: function EE_editAttribute_done(aVal, aCommit) {
          if (!aCommit) {
            return;
          }

          this.undo.startBatch();

          // Remove the attribute stored in this editor and re-add any attributes
          // parsed out of the input element. Restore original attribute if
          // parsing fails.
          this._removeAttribute(this.node, aAttr.name);
          try {
            this._applyAttributes(aVal, attr);
            this.undo.endBatch();
          } catch (e) {
            this.undo.endBatch();
            this.undo.undo();
          }
        }.bind(this)
      });

      this.attrs[aAttr.name] = attr;
    }

    name.textContent = aAttr.name;
    val.textContent = aAttr.value;

    return attr;
  },

  /**
   * Parse a user-entered attribute string and apply the resulting
   * attributes to the node.  This operation is undoable.
   *
   * @param string aValue the user-entered value.
   * @param Element aAttrNode the attribute editor that created this
   *        set of attributes, used to place new attributes where the
   *        user put them.
   */
  _applyAttributes: function EE__applyAttributes(aValue, aAttrNode)
  {
    let attrs = escapeAttributeValues(aValue);

    this.undo.startBatch();

    for (let attr of attrs) {
      let attribute = {
        name: attr.name,
        value: attr.value
      };
      // Create an attribute editor next to the current attribute if needed.
      this._createAttribute(attribute, aAttrNode ? aAttrNode.nextSibling : null);
      this._setAttribute(this.node, attr.name, attr.value);
    }

    this.undo.endBatch();
  },

  /**
   * Helper function for _setAttribute and _removeAttribute,
   * returns a function that puts an attribute back the way it was.
   */
  _restoreAttribute: function EE_restoreAttribute(aNode, aName)
  {
    if (aNode.hasAttribute(aName)) {
      let oldValue = aNode.getAttribute(aName);
      return function() {
        aNode.setAttribute(aName, oldValue);
        this.markup.nodeChanged(aNode);
      }.bind(this);
    } else {
      return function() {
        aNode.removeAttribute(aName);
        this.markup.nodeChanged(aNode);
      }.bind(this);
    }
  },

  /**
   * Sets an attribute.  This operation is undoable.
   */
  _setAttribute: function EE_setAttribute(aNode, aName, aValue)
  {
    this.undo.do(function() {
      aNode.setAttribute(aName, aValue);
      this.markup.nodeChanged(aNode);
    }.bind(this), this._restoreAttribute(aNode, aName));
  },

  /**
   * Removes an attribute.  This operation is undoable.
   */
  _removeAttribute: function EE_removeAttribute(aNode, aName)
  {
    this.undo.do(function() {
      aNode.removeAttribute(aName);
      this.markup.nodeChanged(aNode);
    }.bind(this), this._restoreAttribute(aNode, aName));
  },

  /**
   * Handler for the new attribute editor.
   */
  _onNewAttribute: function EE_onNewAttribute(aValue, aCommit)
  {
    if (!aValue || !aCommit) {
      return;
    }

    this._setAttribute(this.node, aValue, "");
    let attr = this._createAttribute({ name: aValue, value: ""});
    attr.style.removeAttribute("display");
    attr.querySelector("attrvalue").click();
  },


  /**
   * Called when the tag name editor has is done editing.
   */
  onTagEdit: function EE_onTagEdit(aVal, aCommit) {
    if (!aCommit || aVal == this.node.tagName) {
      return;
    }

    // Create a new element with the same attributes as the
    // current element and prepare to replace the current node
    // with it.
    try {
      var newElt = nodeDocument(this.node).createElement(aVal);
    } catch(x) {
      // Failed to create a new element with that tag name, ignore
      // the change.
      return;
    }

    let attrs = this.node.attributes;

    for (let i = 0 ; i < attrs.length; i++) {
      newElt.setAttribute(attrs[i].name, attrs[i].value);
    }

    function swapNodes(aOld, aNew) {
      while (aOld.firstChild) {
        aNew.appendChild(aOld.firstChild);
      }
      aOld.parentNode.insertBefore(aNew, aOld);
      aOld.parentNode.removeChild(aOld);
    }

    let markup = this.container.markup;

    // Queue an action to swap out the element.
    this.undo.do(function() {
      swapNodes(this.node, newElt);

      // Make sure the new node is imported and is expanded/selected
      // the same as the current node.
      let newContainer = markup.importNode(newElt, this.container.expanded);
      newContainer.expanded = this.container.expanded;
      if (this.container.selected) {
        markup.navigate(newContainer);
      }
    }.bind(this), function() {
      swapNodes(newElt, this.node);

      let newContainer = markup._containers.get(newElt);
      this.container.expanded = newContainer.expanded;
      if (newContainer.selected) {
        markup.navigate(this.container);
      }
    }.bind(this));
  },
}



RootContainer.prototype = {
  hasChildren: true,
  expanded: true,
  update: function RC_update() {}
};

function documentWalker(node) {
  return new DocumentWalker(node, Ci.nsIDOMNodeFilter.SHOW_ALL, whitespaceTextFilter);
}

function nodeDocument(node) {
  return node.ownerDocument || (node.nodeType == Ci.nsIDOMNode.DOCUMENT_NODE ? node : null);
}

/**
 * Similar to a TreeWalker, except will dig in to iframes and it doesn't
 * implement the good methods like previousNode and nextNode.
 *
 * See TreeWalker documentation for explanations of the methods.
 */
function DocumentWalker(aNode, aShow, aFilter)
{
  let doc = nodeDocument(aNode);
  this.walker = doc.createTreeWalker(nodeDocument(aNode), aShow, aFilter);
  this.walker.currentNode = aNode;
  this.filter = aFilter;
}

DocumentWalker.prototype = {
  get node() this.walker.node,
  get whatToShow() this.walker.whatToShow,
  get expandEntityReferences() this.walker.expandEntityReferences,
  get currentNode() this.walker.currentNode,
  set currentNode(aVal) this.walker.currentNode = aVal,

  /**
   * Called when the new node is in a different document than
   * the current node, creates a new treewalker for the document we've
   * run in to.
   */
  _reparentWalker: function DW_reparentWalker(aNewNode) {
    if (!aNewNode) {
      return null;
    }
    let doc = nodeDocument(aNewNode);
    let walker = doc.createTreeWalker(doc,
      this.whatToShow, this.filter, this.expandEntityReferences);
    walker.currentNode = aNewNode;
    this.walker = walker;
    return aNewNode;
  },

  parentNode: function DW_parentNode()
  {
    let currentNode = this.walker.currentNode;
    let parentNode = this.walker.parentNode();

    if (!parentNode) {
      if (currentNode && currentNode.nodeType == Ci.nsIDOMNode.DOCUMENT_NODE
          && currentNode.defaultView) {
        let embeddingFrame = currentNode.defaultView.frameElement;
        if (embeddingFrame) {
          return this._reparentWalker(embeddingFrame);
        }
      }
      return null;
    }

    return parentNode;
  },

  firstChild: function DW_firstChild()
  {
    let node = this.walker.currentNode;
    if (!node)
      return;
    if (node.contentDocument) {
      return this._reparentWalker(node.contentDocument);
    } else if (node.getSVGDocument) {
      return this._reparentWalker(node.getSVGDocument());
    }
    return this.walker.firstChild();
  },

  lastChild: function DW_lastChild()
  {
    let node = this.walker.currentNode;
    if (!node)
      return;
    if (node.contentDocument) {
      return this._reparentWalker(node.contentDocument);
    } else if (node.getSVGDocument) {
      return this._reparentWalker(node.getSVGDocument());
    }
    return this.walker.lastChild();
  },

  previousSibling: function DW_previousSibling() this.walker.previousSibling(),
  nextSibling: function DW_nextSibling() this.walker.nextSibling(),

  // XXX bug 785143: not doing previousNode or nextNode, which would sure be useful.
};

/**
 * Properly escape attribute values.
 *
 * @param  {String} attr
 *         The attributes for which the values are to be escaped.
 * @return {Array}
 *         An array of attribute names and their escaped values.
 */
function escapeAttributeValues(attr) {
  let name = null;
  let value = null;
  let result = "";
  let attributes = [];

  while(attr.length > 0) {
    let match;
    let dirty = false;

    // Trim quotes and spaces from attr start
    match = attr.match(/^["\s]+/);
    if (match && match.length == 1) {
      attr = attr.substr(match[0].length);
    }

    // Name
    if (!dirty) {
      match = attr.match(/^([\w-]+)="/);
      if (match && match.length == 2) {
        if (name) {
          // We had a name without a value e.g. disabled. Let's set the value to "";
          value = "";
        } else {
          name = match[1];
          attr = attr.substr(match[0].length);
        }
        dirty = true;
      }
    }

    // Value (in the case of multiple attributes)
    if (!dirty) {
      match = attr.match(/^(.+?)"\s+[\w-]+="/);
      if (match && match.length > 1) {
        value = typeof match[1] == "undefined" ? match[2] : match[1];
        attr = attr.substr(value.length);
        value = simpleEscape(value);
        dirty = true;
      }
    }

    // Final value
    if (!dirty && attr.indexOf("=\"") == -1) {
      // No more attributes, get the remaining value minus it's ending quote.
      if (attr.charAt(attr.length - 1) == '"') {
        attr = attr.substr(0, attr.length - 1);
      }

      if (!name) {
        name = attr;
        value = "";
      } else {
        value = simpleEscape(attr);
      }
      attr = "";
      dirty = true;
    }

    if (name !== null && value !== null) {
      attributes.push({name: name, value: value});
      name = value = null;
    }

    if (!dirty) {
      // This should never happen but we exit here if it does.
      return attributes;
    }
  }
  return attributes;
}

/**
 * Escape basic html entities <, >, " and '.
 * @param  {String} value
 *         Value to escape.
 * @return {String}
 *         Escaped value.
 */
function simpleEscape(value) {
  return value.replace(/</g, "&lt;")
              .replace(/>/g, "&gt;")
              .replace(/"/g, "&quot;")
              .replace(/'/g, "&apos;");
}

/**
 * A tree walker filter for avoiding empty whitespace text nodes.
 */
function whitespaceTextFilter(aNode)
{
    if (aNode.nodeType == Ci.nsIDOMNode.TEXT_NODE &&
        !/[^\s]/.exec(aNode.nodeValue)) {
      return Ci.nsIDOMNodeFilter.FILTER_SKIP;
    } else {
      return Ci.nsIDOMNodeFilter.FILTER_ACCEPT;
    }
}

loader.lazyGetter(MarkupView.prototype, "strings", () => Services.strings.createBundle(
  "chrome://browser/locale/devtools/inspector.properties"
));
