summaryrefslogtreecommitdiffstats
path: root/devtools/client/inspector/markup/markup.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/inspector/markup/markup.js')
-rw-r--r--devtools/client/inspector/markup/markup.js1878
1 files changed, 1878 insertions, 0 deletions
diff --git a/devtools/client/inspector/markup/markup.js b/devtools/client/inspector/markup/markup.js
new file mode 100644
index 000000000..d6e9f8c11
--- /dev/null
+++ b/devtools/client/inspector/markup/markup.js
@@ -0,0 +1,1878 @@
+/* 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/. */
+
+"use strict";
+
+const promise = require("promise");
+const Services = require("Services");
+const defer = require("devtools/shared/defer");
+const {Task} = require("devtools/shared/task");
+const nodeConstants = require("devtools/shared/dom-node-constants");
+const nodeFilterConstants = require("devtools/shared/dom-node-filter-constants");
+const EventEmitter = require("devtools/shared/event-emitter");
+const {LocalizationHelper} = require("devtools/shared/l10n");
+const {PluralForm} = require("devtools/shared/plural-form");
+const {template} = require("devtools/shared/gcli/templater");
+const {AutocompletePopup} = require("devtools/client/shared/autocomplete-popup");
+const {KeyShortcuts} = require("devtools/client/shared/key-shortcuts");
+const {scrollIntoViewIfNeeded} = require("devtools/client/shared/scroll");
+const {UndoStack} = require("devtools/client/shared/undo");
+const {HTMLTooltip} = require("devtools/client/shared/widgets/tooltip/HTMLTooltip");
+const {PrefObserver} = require("devtools/client/styleeditor/utils");
+const HTMLEditor = require("devtools/client/inspector/markup/views/html-editor");
+const MarkupElementContainer = require("devtools/client/inspector/markup/views/element-container");
+const MarkupReadOnlyContainer = require("devtools/client/inspector/markup/views/read-only-container");
+const MarkupTextContainer = require("devtools/client/inspector/markup/views/text-container");
+const RootContainer = require("devtools/client/inspector/markup/views/root-container");
+
+const INSPECTOR_L10N =
+ new LocalizationHelper("devtools/client/locales/inspector.properties");
+
+// Page size for pageup/pagedown
+const PAGE_SIZE = 10;
+const DEFAULT_MAX_CHILDREN = 100;
+const NEW_SELECTION_HIGHLIGHTER_TIMER = 1000;
+const DRAG_DROP_AUTOSCROLL_EDGE_MAX_DISTANCE = 50;
+const DRAG_DROP_AUTOSCROLL_EDGE_RATIO = 0.1;
+const DRAG_DROP_MIN_AUTOSCROLL_SPEED = 2;
+const DRAG_DROP_MAX_AUTOSCROLL_SPEED = 8;
+const DRAG_DROP_HEIGHT_TO_SPEED = 500;
+const DRAG_DROP_HEIGHT_TO_SPEED_MIN = 0.5;
+const DRAG_DROP_HEIGHT_TO_SPEED_MAX = 1;
+const ATTR_COLLAPSE_ENABLED_PREF = "devtools.markup.collapseAttributes";
+const ATTR_COLLAPSE_LENGTH_PREF = "devtools.markup.collapseAttributeLength";
+
+/**
+ * Vocabulary for the purposes of this file:
+ *
+ * MarkupContainer - the structure that holds an editor and its
+ * immediate children in the markup panel.
+ * - MarkupElementContainer: markup container for element nodes
+ * - MarkupTextContainer: markup container for text / comment nodes
+ * - MarkupReadonlyContainer: markup container for other nodes
+ * 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} inspector
+ * The inspector we're watching.
+ * @param {iframe} frame
+ * An iframe in which the caller has kindly loaded markup.xhtml.
+ */
+function MarkupView(inspector, frame, controllerWindow) {
+ this.inspector = inspector;
+ this.walker = this.inspector.walker;
+ this._frame = frame;
+ this.win = this._frame.contentWindow;
+ this.doc = this._frame.contentDocument;
+ this._elt = this.doc.querySelector("#root");
+ this.htmlEditor = new HTMLEditor(this.doc);
+
+ try {
+ this.maxChildren = Services.prefs.getIntPref("devtools.markup.pagesize");
+ } catch (ex) {
+ this.maxChildren = DEFAULT_MAX_CHILDREN;
+ }
+
+ this.collapseAttributes =
+ Services.prefs.getBoolPref(ATTR_COLLAPSE_ENABLED_PREF);
+ this.collapseAttributeLength =
+ Services.prefs.getIntPref(ATTR_COLLAPSE_LENGTH_PREF);
+
+ // Creating the popup to be used to show CSS suggestions.
+ // The popup will be attached to the toolbox document.
+ this.popup = new AutocompletePopup(inspector.toolbox.doc, {
+ autoSelect: true,
+ theme: "auto",
+ });
+
+ this.undo = new UndoStack();
+ this.undo.installController(controllerWindow);
+
+ this._containers = new Map();
+
+ // Binding functions that need to be called in scope.
+ this._handleRejectionIfNotDestroyed = this._handleRejectionIfNotDestroyed.bind(this);
+ this._mutationObserver = this._mutationObserver.bind(this);
+ this._onDisplayChange = this._onDisplayChange.bind(this);
+ this._onMouseClick = this._onMouseClick.bind(this);
+ this._onMouseUp = this._onMouseUp.bind(this);
+ this._onNewSelection = this._onNewSelection.bind(this);
+ this._onCopy = this._onCopy.bind(this);
+ this._onFocus = this._onFocus.bind(this);
+ this._onMouseMove = this._onMouseMove.bind(this);
+ this._onMouseOut = this._onMouseOut.bind(this);
+ this._onToolboxPickerCanceled = this._onToolboxPickerCanceled.bind(this);
+ this._onToolboxPickerHover = this._onToolboxPickerHover.bind(this);
+ this._onCollapseAttributesPrefChange =
+ this._onCollapseAttributesPrefChange.bind(this);
+ this._isImagePreviewTarget = this._isImagePreviewTarget.bind(this);
+ this._onBlur = this._onBlur.bind(this);
+
+ EventEmitter.decorate(this);
+
+ // Listening to various events.
+ this._elt.addEventListener("click", this._onMouseClick, false);
+ this._elt.addEventListener("mousemove", this._onMouseMove, false);
+ this._elt.addEventListener("mouseout", this._onMouseOut, false);
+ this._elt.addEventListener("blur", this._onBlur, true);
+ this.win.addEventListener("mouseup", this._onMouseUp);
+ this.win.addEventListener("copy", this._onCopy);
+ this._frame.addEventListener("focus", this._onFocus, false);
+ this.walker.on("mutations", this._mutationObserver);
+ this.walker.on("display-change", this._onDisplayChange);
+ this.inspector.selection.on("new-node-front", this._onNewSelection);
+ this.toolbox.on("picker-canceled", this._onToolboxPickerCanceled);
+ this.toolbox.on("picker-node-hovered", this._onToolboxPickerHover);
+
+ this._onNewSelection();
+ this._initTooltips();
+
+ this._prefObserver = new PrefObserver("devtools.markup");
+ this._prefObserver.on(ATTR_COLLAPSE_ENABLED_PREF,
+ this._onCollapseAttributesPrefChange);
+ this._prefObserver.on(ATTR_COLLAPSE_LENGTH_PREF,
+ this._onCollapseAttributesPrefChange);
+
+ this._initShortcuts();
+}
+
+MarkupView.prototype = {
+ /**
+ * How long does a node flash when it mutates (in ms).
+ */
+ CONTAINER_FLASHING_DURATION: 500,
+
+ _selectedContainer: null,
+
+ get toolbox() {
+ return this.inspector.toolbox;
+ },
+
+ /**
+ * Handle promise rejections for various asynchronous actions, and only log errors if
+ * the markup view still exists.
+ * This is useful to silence useless errors that happen when the markup view is
+ * destroyed while still initializing (and making protocol requests).
+ */
+ _handleRejectionIfNotDestroyed: function (e) {
+ if (!this._destroyer) {
+ console.error(e);
+ }
+ },
+
+ _initTooltips: function () {
+ // The tooltips will be attached to the toolbox document.
+ this.eventDetailsTooltip = new HTMLTooltip(this.toolbox.doc,
+ {type: "arrow"});
+ this.imagePreviewTooltip = new HTMLTooltip(this.toolbox.doc,
+ {type: "arrow", useXulWrapper: "true"});
+ this._enableImagePreviewTooltip();
+ },
+
+ _enableImagePreviewTooltip: function () {
+ this.imagePreviewTooltip.startTogglingOnHover(this._elt,
+ this._isImagePreviewTarget);
+ },
+
+ _disableImagePreviewTooltip: function () {
+ this.imagePreviewTooltip.stopTogglingOnHover();
+ },
+
+ _onToolboxPickerHover: function (event, nodeFront) {
+ this.showNode(nodeFront).then(() => {
+ this._showContainerAsHovered(nodeFront);
+ }, e => console.error(e));
+ },
+
+ /**
+ * If the element picker gets canceled, make sure and re-center the view on the
+ * current selected element.
+ */
+ _onToolboxPickerCanceled: function () {
+ if (this._selectedContainer) {
+ scrollIntoViewIfNeeded(this._selectedContainer.editor.elt);
+ }
+ },
+
+ isDragging: false,
+
+ _onMouseMove: function (event) {
+ let target = event.target;
+
+ // Auto-scroll if we're dragging.
+ if (this.isDragging) {
+ event.preventDefault();
+ this._autoScroll(event);
+ return;
+ }
+
+ // Show the current container as hovered and highlight it.
+ // This requires finding the current MarkupContainer (walking up the DOM).
+ while (!target.container) {
+ if (target.tagName.toLowerCase() === "body") {
+ return;
+ }
+ target = target.parentNode;
+ }
+
+ let container = target.container;
+ if (this._hoveredNode !== container.node) {
+ this._showBoxModel(container.node);
+ }
+ this._showContainerAsHovered(container.node);
+
+ this.emit("node-hover");
+ },
+
+ /**
+ * If focus is moved outside of the markup view document and there is a
+ * selected container, make its contents not focusable by a keyboard.
+ */
+ _onBlur: function (event) {
+ if (!this._selectedContainer) {
+ return;
+ }
+
+ let {relatedTarget} = event;
+ if (relatedTarget && relatedTarget.ownerDocument === this.doc) {
+ return;
+ }
+
+ if (this._selectedContainer) {
+ this._selectedContainer.clearFocus();
+ }
+ },
+
+ /**
+ * Executed on each mouse-move while a node is being dragged in the view.
+ * Auto-scrolls the view to reveal nodes below the fold to drop the dragged
+ * node in.
+ */
+ _autoScroll: function (event) {
+ let docEl = this.doc.documentElement;
+
+ if (this._autoScrollAnimationFrame) {
+ this.win.cancelAnimationFrame(this._autoScrollAnimationFrame);
+ }
+
+ // Auto-scroll when the mouse approaches top/bottom edge.
+ let fromBottom = docEl.clientHeight - event.pageY + this.win.scrollY;
+ let fromTop = event.pageY - this.win.scrollY;
+ let edgeDistance = Math.min(DRAG_DROP_AUTOSCROLL_EDGE_MAX_DISTANCE,
+ docEl.clientHeight * DRAG_DROP_AUTOSCROLL_EDGE_RATIO);
+
+ // The smaller the screen, the slower the movement.
+ let heightToSpeedRatio =
+ Math.max(DRAG_DROP_HEIGHT_TO_SPEED_MIN,
+ Math.min(DRAG_DROP_HEIGHT_TO_SPEED_MAX,
+ docEl.clientHeight / DRAG_DROP_HEIGHT_TO_SPEED));
+
+ if (fromBottom <= edgeDistance) {
+ // Map our distance range to a speed range so that the speed is not too
+ // fast or too slow.
+ let speed = map(
+ fromBottom,
+ 0, edgeDistance,
+ DRAG_DROP_MIN_AUTOSCROLL_SPEED, DRAG_DROP_MAX_AUTOSCROLL_SPEED);
+
+ this._runUpdateLoop(() => {
+ docEl.scrollTop -= heightToSpeedRatio *
+ (speed - DRAG_DROP_MAX_AUTOSCROLL_SPEED);
+ });
+ }
+
+ if (fromTop <= edgeDistance) {
+ let speed = map(
+ fromTop,
+ 0, edgeDistance,
+ DRAG_DROP_MIN_AUTOSCROLL_SPEED, DRAG_DROP_MAX_AUTOSCROLL_SPEED);
+
+ this._runUpdateLoop(() => {
+ docEl.scrollTop += heightToSpeedRatio *
+ (speed - DRAG_DROP_MAX_AUTOSCROLL_SPEED);
+ });
+ }
+ },
+
+ /**
+ * Run a loop on the requestAnimationFrame.
+ */
+ _runUpdateLoop: function (update) {
+ let loop = () => {
+ update();
+ this._autoScrollAnimationFrame = this.win.requestAnimationFrame(loop);
+ };
+ loop();
+ },
+
+ _onMouseClick: function (event) {
+ // From the target passed here, let's find the parent MarkupContainer
+ // and ask it if the tooltip should be shown
+ let parentNode = event.target;
+ let container;
+ while (parentNode !== this.doc.body) {
+ if (parentNode.container) {
+ container = parentNode.container;
+ break;
+ }
+ parentNode = parentNode.parentNode;
+ }
+
+ if (container instanceof MarkupElementContainer) {
+ // With the newly found container, delegate the tooltip content creation
+ // and decision to show or not the tooltip
+ container._buildEventTooltipContent(event.target,
+ this.eventDetailsTooltip);
+ }
+ },
+
+ _onMouseUp: function () {
+ this.indicateDropTarget(null);
+ this.indicateDragTarget(null);
+ if (this._autoScrollAnimationFrame) {
+ this.win.cancelAnimationFrame(this._autoScrollAnimationFrame);
+ }
+ },
+
+ _onCollapseAttributesPrefChange: function () {
+ this.collapseAttributes =
+ Services.prefs.getBoolPref(ATTR_COLLAPSE_ENABLED_PREF);
+ this.collapseAttributeLength =
+ Services.prefs.getIntPref(ATTR_COLLAPSE_LENGTH_PREF);
+ this.update();
+ },
+
+ cancelDragging: function () {
+ if (!this.isDragging) {
+ return;
+ }
+
+ for (let [, container] of this._containers) {
+ if (container.isDragging) {
+ container.cancelDragging();
+ break;
+ }
+ }
+
+ this.indicateDropTarget(null);
+ this.indicateDragTarget(null);
+ if (this._autoScrollAnimationFrame) {
+ this.win.cancelAnimationFrame(this._autoScrollAnimationFrame);
+ }
+ },
+
+ _hoveredNode: null,
+
+ /**
+ * Show a NodeFront's container as being hovered
+ *
+ * @param {NodeFront} nodeFront
+ * The node to show as hovered
+ */
+ _showContainerAsHovered: function (nodeFront) {
+ if (this._hoveredNode === nodeFront) {
+ return;
+ }
+
+ if (this._hoveredNode) {
+ this.getContainer(this._hoveredNode).hovered = false;
+ }
+
+ this.getContainer(nodeFront).hovered = true;
+ this._hoveredNode = nodeFront;
+ // Emit an event that the container view is actually hovered now, as this function
+ // can be called by an asynchronous caller.
+ this.emit("showcontainerhovered");
+ },
+
+ _onMouseOut: function (event) {
+ // Emulate mouseleave by skipping any relatedTarget inside the markup-view.
+ if (this._elt.contains(event.relatedTarget)) {
+ return;
+ }
+
+ if (this._autoScrollAnimationFrame) {
+ this.win.cancelAnimationFrame(this._autoScrollAnimationFrame);
+ }
+ if (this.isDragging) {
+ return;
+ }
+
+ this._hideBoxModel(true);
+ if (this._hoveredNode) {
+ this.getContainer(this._hoveredNode).hovered = false;
+ }
+ this._hoveredNode = null;
+
+ this.emit("leave");
+ },
+
+ /**
+ * Show the box model highlighter on a given node front
+ *
+ * @param {NodeFront} nodeFront
+ * The node to show the highlighter for
+ * @return {Promise} Resolves when the highlighter for this nodeFront is
+ * shown, taking into account that there could already be highlighter
+ * requests queued up
+ */
+ _showBoxModel: function (nodeFront) {
+ return this.toolbox.highlighterUtils.highlightNodeFront(nodeFront);
+ },
+
+ /**
+ * Hide the box model highlighter on a given node front
+ *
+ * @param {Boolean} forceHide
+ * See toolbox-highlighter-utils/unhighlight
+ * @return {Promise} Resolves when the highlighter for this nodeFront is
+ * hidden, taking into account that there could already be highlighter
+ * requests queued up
+ */
+ _hideBoxModel: function (forceHide) {
+ return this.toolbox.highlighterUtils.unhighlight(forceHide);
+ },
+
+ _briefBoxModelTimer: null,
+
+ _clearBriefBoxModelTimer: function () {
+ if (this._briefBoxModelTimer) {
+ clearTimeout(this._briefBoxModelTimer);
+ this._briefBoxModelPromise.resolve();
+ this._briefBoxModelPromise = null;
+ this._briefBoxModelTimer = null;
+ }
+ },
+
+ _brieflyShowBoxModel: function (nodeFront) {
+ this._clearBriefBoxModelTimer();
+ let onShown = this._showBoxModel(nodeFront);
+ this._briefBoxModelPromise = defer();
+
+ this._briefBoxModelTimer = setTimeout(() => {
+ this._hideBoxModel()
+ .then(this._briefBoxModelPromise.resolve,
+ this._briefBoxModelPromise.resolve);
+ }, NEW_SELECTION_HIGHLIGHTER_TIMER);
+
+ return promise.all([onShown, this._briefBoxModelPromise.promise]);
+ },
+
+ template: function (name, dest, options = {stack: "markup.xhtml"}) {
+ let node = this.doc.getElementById("template-" + name).cloneNode(true);
+ node.removeAttribute("id");
+ template(node, dest, options);
+ return node;
+ },
+
+ /**
+ * Get the MarkupContainer object for a given node, or undefined if
+ * none exists.
+ */
+ getContainer: function (node) {
+ return this._containers.get(node);
+ },
+
+ update: function () {
+ let updateChildren = (node) => {
+ this.getContainer(node).update();
+ for (let child of node.treeChildren()) {
+ updateChildren(child);
+ }
+ };
+
+ // Start with the documentElement
+ let documentElement;
+ for (let node of this._rootNode.treeChildren()) {
+ if (node.isDocumentElement === true) {
+ documentElement = node;
+ break;
+ }
+ }
+
+ // Recursively update each node starting with documentElement.
+ updateChildren(documentElement);
+ },
+
+ /**
+ * Executed when the mouse hovers over a target in the markup-view and is used
+ * to decide whether this target should be used to display an image preview
+ * tooltip.
+ * Delegates the actual decision to the corresponding MarkupContainer instance
+ * if one is found.
+ *
+ * @return {Promise} the promise returned by
+ * MarkupElementContainer._isImagePreviewTarget
+ */
+ _isImagePreviewTarget: Task.async(function* (target) {
+ // From the target passed here, let's find the parent MarkupContainer
+ // and ask it if the tooltip should be shown
+ if (this.isDragging) {
+ return false;
+ }
+
+ let parent = target, container;
+ while (parent !== this.doc.body) {
+ if (parent.container) {
+ container = parent.container;
+ break;
+ }
+ parent = parent.parentNode;
+ }
+
+ if (container instanceof MarkupElementContainer) {
+ // With the newly found container, delegate the tooltip content creation
+ // and decision to show or not the tooltip
+ return container.isImagePreviewTarget(target, this.imagePreviewTooltip);
+ }
+
+ return false;
+ }),
+
+ /**
+ * Given the known reason, should the current selection be briefly highlighted
+ * In a few cases, we don't want to highlight the node:
+ * - If the reason is null (used to reset the selection),
+ * - if it's "inspector-open" (when the inspector opens up, let's not
+ * highlight the default node)
+ * - if it's "navigateaway" (since the page is being navigated away from)
+ * - if it's "test" (this is a special case for mochitest. In tests, we often
+ * need to select elements but don't necessarily want the highlighter to come
+ * and go after a delay as this might break test scenarios)
+ * We also do not want to start a brief highlight timeout if the node is
+ * already being hovered over, since in that case it will already be
+ * highlighted.
+ */
+ _shouldNewSelectionBeHighlighted: function () {
+ let reason = this.inspector.selection.reason;
+ let unwantedReasons = [
+ "inspector-open",
+ "navigateaway",
+ "nodeselected",
+ "test"
+ ];
+ let isHighlight = this._hoveredNode === this.inspector.selection.nodeFront;
+ return !isHighlight && reason && unwantedReasons.indexOf(reason) === -1;
+ },
+
+ /**
+ * React to new-node-front selection events.
+ * Highlights the node if needed, and make sure it is shown and selected in
+ * the view.
+ */
+ _onNewSelection: function () {
+ let selection = this.inspector.selection;
+
+ this.htmlEditor.hide();
+ if (this._hoveredNode && this._hoveredNode !== selection.nodeFront) {
+ this.getContainer(this._hoveredNode).hovered = false;
+ this._hoveredNode = null;
+ }
+
+ if (!selection.isNode()) {
+ this.unmarkSelectedNode();
+ return;
+ }
+
+ let done = this.inspector.updating("markup-view");
+ let onShowBoxModel, onShow;
+
+ // Highlight the element briefly if needed.
+ if (this._shouldNewSelectionBeHighlighted()) {
+ onShowBoxModel = this._brieflyShowBoxModel(selection.nodeFront);
+ }
+
+ onShow = this.showNode(selection.nodeFront).then(() => {
+ // We could be destroyed by now.
+ if (this._destroyer) {
+ return promise.reject("markupview destroyed");
+ }
+
+ // Mark the node as selected.
+ this.markNodeAsSelected(selection.nodeFront);
+
+ // Make sure the new selection is navigated to.
+ this.maybeNavigateToNewSelection();
+ return undefined;
+ }).catch(this._handleRejectionIfNotDestroyed);
+
+ promise.all([onShowBoxModel, onShow]).then(done);
+ },
+
+ /**
+ * Maybe make selected the current node selection's MarkupContainer depending
+ * on why the current node got selected.
+ */
+ maybeNavigateToNewSelection: function () {
+ let {reason, nodeFront} = this.inspector.selection;
+
+ // The list of reasons that should lead to navigating to the node.
+ let reasonsToNavigate = [
+ // If the user picked an element with the element picker.
+ "picker-node-picked",
+ // If the user shift-clicked (previewed) an element.
+ "picker-node-previewed",
+ // If the user selected an element with the browser context menu.
+ "browser-context-menu",
+ // If the user added a new node by clicking in the inspector toolbar.
+ "node-inserted"
+ ];
+
+ if (reasonsToNavigate.includes(reason)) {
+ this.getContainer(this._rootNode).elt.focus();
+ this.navigate(this.getContainer(nodeFront));
+ }
+ },
+
+ /**
+ * Create a TreeWalker to find the next/previous
+ * node for selection.
+ */
+ _selectionWalker: function (start) {
+ let walker = this.doc.createTreeWalker(
+ start || this._elt,
+ nodeFilterConstants.SHOW_ELEMENT,
+ function (element) {
+ if (element.container &&
+ element.container.elt === element &&
+ element.container.visible) {
+ return nodeFilterConstants.FILTER_ACCEPT;
+ }
+ return nodeFilterConstants.FILTER_SKIP;
+ }
+ );
+ walker.currentNode = this._selectedContainer.elt;
+ return walker;
+ },
+
+ _onCopy: function (evt) {
+ // Ignore copy events from editors
+ if (this._isInputOrTextarea(evt.target)) {
+ return;
+ }
+
+ let selection = this.inspector.selection;
+ if (selection.isNode()) {
+ this.inspector.copyOuterHTML();
+ }
+ evt.stopPropagation();
+ evt.preventDefault();
+ },
+
+ /**
+ * Register all key shortcuts.
+ */
+ _initShortcuts: function () {
+ let shortcuts = new KeyShortcuts({
+ window: this.win,
+ });
+
+ this._onShortcut = this._onShortcut.bind(this);
+
+ // Process localizable keys
+ ["markupView.hide.key",
+ "markupView.edit.key",
+ "markupView.scrollInto.key"].forEach(name => {
+ let key = INSPECTOR_L10N.getStr(name);
+ shortcuts.on(key, (_, event) => this._onShortcut(name, event));
+ });
+
+ // Process generic keys:
+ ["Delete", "Backspace", "Home", "Left", "Right", "Up", "Down", "PageUp",
+ "PageDown", "Esc", "Enter", "Space"].forEach(key => {
+ shortcuts.on(key, this._onShortcut);
+ });
+ },
+
+ /**
+ * Key shortcut listener.
+ */
+ _onShortcut(name, event) {
+ if (this._isInputOrTextarea(event.target)) {
+ return;
+ }
+ switch (name) {
+ // Localizable keys
+ case "markupView.hide.key": {
+ let node = this._selectedContainer.node;
+ if (node.hidden) {
+ this.walker.unhideNode(node);
+ } else {
+ this.walker.hideNode(node);
+ }
+ break;
+ }
+ case "markupView.edit.key": {
+ this.beginEditingOuterHTML(this._selectedContainer.node);
+ break;
+ }
+ case "markupView.scrollInto.key": {
+ let selection = this._selectedContainer.node;
+ this.inspector.scrollNodeIntoView(selection);
+ break;
+ }
+ // Generic keys
+ case "Delete": {
+ this.deleteNodeOrAttribute();
+ break;
+ }
+ case "Backspace": {
+ this.deleteNodeOrAttribute(true);
+ break;
+ }
+ case "Home": {
+ let rootContainer = this.getContainer(this._rootNode);
+ this.navigate(rootContainer.children.firstChild.container);
+ break;
+ }
+ case "Left": {
+ if (this._selectedContainer.expanded) {
+ this.collapseNode(this._selectedContainer.node);
+ } else {
+ let parent = this._selectionWalker().parentNode();
+ if (parent) {
+ this.navigate(parent.container);
+ }
+ }
+ break;
+ }
+ case "Right": {
+ if (!this._selectedContainer.expanded &&
+ this._selectedContainer.hasChildren) {
+ this._expandContainer(this._selectedContainer);
+ } else {
+ let next = this._selectionWalker().nextNode();
+ if (next) {
+ this.navigate(next.container);
+ }
+ }
+ break;
+ }
+ case "Up": {
+ let previousNode = this._selectionWalker().previousNode();
+ if (previousNode) {
+ this.navigate(previousNode.container);
+ }
+ break;
+ }
+ case "Down": {
+ let nextNode = this._selectionWalker().nextNode();
+ if (nextNode) {
+ this.navigate(nextNode.container);
+ }
+ break;
+ }
+ case "PageUp": {
+ let walker = this._selectionWalker();
+ let selection = this._selectedContainer;
+ for (let i = 0; i < PAGE_SIZE; i++) {
+ let previousNode = walker.previousNode();
+ if (!previousNode) {
+ break;
+ }
+ selection = previousNode.container;
+ }
+ this.navigate(selection);
+ break;
+ }
+ case "PageDown": {
+ let walker = this._selectionWalker();
+ let selection = this._selectedContainer;
+ for (let i = 0; i < PAGE_SIZE; i++) {
+ let nextNode = walker.nextNode();
+ if (!nextNode) {
+ break;
+ }
+ selection = nextNode.container;
+ }
+ this.navigate(selection);
+ break;
+ }
+ case "Enter":
+ case "Space": {
+ if (!this._selectedContainer.canFocus) {
+ this._selectedContainer.canFocus = true;
+ this._selectedContainer.focus();
+ } else {
+ // Return early to prevent cancelling the event.
+ return;
+ }
+ break;
+ }
+ case "Esc": {
+ if (this.isDragging) {
+ this.cancelDragging();
+ } else {
+ // Return early to prevent cancelling the event when not
+ // dragging, to allow the split console to be toggled.
+ return;
+ }
+ break;
+ }
+ default:
+ console.error("Unexpected markup-view key shortcut", name);
+ return;
+ }
+ // Prevent default for this action
+ event.stopPropagation();
+ event.preventDefault();
+ },
+
+ /**
+ * Check if a node is an input or textarea
+ */
+ _isInputOrTextarea: function (element) {
+ let name = element.tagName.toLowerCase();
+ return name === "input" || name === "textarea";
+ },
+
+ /**
+ * If there's an attribute on the current node that's currently focused, then
+ * delete this attribute, otherwise delete the node itself.
+ *
+ * @param {Boolean} moveBackward
+ * If set to true and if we're deleting the node, focus the previous
+ * sibling after deletion, otherwise the next one.
+ */
+ deleteNodeOrAttribute: function (moveBackward) {
+ let focusedAttribute = this.doc.activeElement
+ ? this.doc.activeElement.closest(".attreditor")
+ : null;
+ if (focusedAttribute) {
+ // The focused attribute might not be in the current selected container.
+ let container = focusedAttribute.closest("li.child").container;
+ container.removeAttribute(focusedAttribute.dataset.attr);
+ } else {
+ this.deleteNode(this._selectedContainer.node, moveBackward);
+ }
+ },
+
+ /**
+ * Delete a node from the DOM.
+ * This is an undoable action.
+ *
+ * @param {NodeFront} node
+ * The node to remove.
+ * @param {Boolean} moveBackward
+ * If set to true, focus the previous sibling, otherwise the next one.
+ */
+ deleteNode: function (node, moveBackward) {
+ if (node.isDocumentElement ||
+ node.nodeType == nodeConstants.DOCUMENT_TYPE_NODE ||
+ node.isAnonymous) {
+ return;
+ }
+
+ let container = this.getContainer(node);
+
+ // Retain the node so we can undo this...
+ this.walker.retainNode(node).then(() => {
+ let parent = node.parentNode();
+ let nextSibling = null;
+ this.undo.do(() => {
+ this.walker.removeNode(node).then(siblings => {
+ nextSibling = siblings.nextSibling;
+ let prevSibling = siblings.previousSibling;
+ let focusNode = moveBackward ? prevSibling : nextSibling;
+
+ // If we can't move as the user wants, we move to the other direction.
+ // If there is no sibling elements anymore, move to the parent node.
+ if (!focusNode) {
+ focusNode = nextSibling || prevSibling || parent;
+ }
+
+ let isNextSiblingText = nextSibling ?
+ nextSibling.nodeType === nodeConstants.TEXT_NODE : false;
+ let isPrevSiblingText = prevSibling ?
+ prevSibling.nodeType === nodeConstants.TEXT_NODE : false;
+
+ // If the parent had two children and the next or previous sibling
+ // is a text node, then it now has only a single text node, is about
+ // to be in-lined; and focus should move to the parent.
+ if (parent.numChildren === 2
+ && (isNextSiblingText || isPrevSiblingText)) {
+ focusNode = parent;
+ }
+
+ if (container.selected) {
+ this.navigate(this.getContainer(focusNode));
+ }
+ });
+ }, () => {
+ let isValidSibling = nextSibling && !nextSibling.isPseudoElement;
+ nextSibling = isValidSibling ? nextSibling : null;
+ this.walker.insertBefore(node, parent, nextSibling);
+ });
+ }).then(null, console.error);
+ },
+
+ /**
+ * If an editable item is focused, select its container.
+ */
+ _onFocus: function (event) {
+ let parent = event.target;
+ while (!parent.container) {
+ parent = parent.parentNode;
+ }
+ if (parent) {
+ this.navigate(parent.container);
+ }
+ },
+
+ /**
+ * Handle a user-requested navigation to a given MarkupContainer,
+ * updating the inspector's currently-selected node.
+ *
+ * @param {MarkupContainer} container
+ * The container we're navigating to.
+ */
+ navigate: function (container) {
+ if (!container) {
+ return;
+ }
+
+ let node = container.node;
+ this.markNodeAsSelected(node, "treepanel");
+ },
+
+ /**
+ * Make sure a node is included in the markup tool.
+ *
+ * @param {NodeFront} node
+ * The node in the content document.
+ * @param {Boolean} flashNode
+ * Whether the newly imported node should be flashed
+ * @return {MarkupContainer} The MarkupContainer object for this element.
+ */
+ importNode: function (node, flashNode) {
+ if (!node) {
+ return null;
+ }
+
+ if (this._containers.has(node)) {
+ return this.getContainer(node);
+ }
+
+ let container;
+ let {nodeType, isPseudoElement} = node;
+ if (node === this.walker.rootNode) {
+ container = new RootContainer(this, node);
+ this._elt.appendChild(container.elt);
+ this._rootNode = node;
+ } else if (nodeType == nodeConstants.ELEMENT_NODE && !isPseudoElement) {
+ container = new MarkupElementContainer(this, node, this.inspector);
+ } else if (nodeType == nodeConstants.COMMENT_NODE ||
+ nodeType == nodeConstants.TEXT_NODE) {
+ container = new MarkupTextContainer(this, node, this.inspector);
+ } else {
+ container = new MarkupReadOnlyContainer(this, node, this.inspector);
+ }
+
+ if (flashNode) {
+ container.flashMutation();
+ }
+
+ this._containers.set(node, container);
+ container.childrenDirty = true;
+
+ this._updateChildren(container);
+
+ this.inspector.emit("container-created", container);
+
+ return container;
+ },
+
+ /**
+ * Mutation observer used for included nodes.
+ */
+ _mutationObserver: function (mutations) {
+ for (let mutation of mutations) {
+ let type = mutation.type;
+ let target = mutation.target;
+
+ if (mutation.type === "documentUnload") {
+ // Treat this as a childList change of the child (maybe the protocol
+ // should do this).
+ type = "childList";
+ target = mutation.targetParent;
+ if (!target) {
+ continue;
+ }
+ }
+
+ let container = this.getContainer(target);
+ if (!container) {
+ // Container might not exist if this came from a load event for a node
+ // we're not viewing.
+ continue;
+ }
+
+ if (type === "attributes" && mutation.attributeName === "class") {
+ container.updateIsDisplayed();
+ }
+ if (type === "attributes" || type === "characterData"
+ || type === "events" || type === "pseudoClassLock") {
+ container.update();
+ } else if (type === "childList" || type === "nativeAnonymousChildList") {
+ container.childrenDirty = true;
+ // Update the children to take care of changes in the markup view DOM
+ // and update container (and its subtree) DOM tree depth level for
+ // accessibility where necessary.
+ this._updateChildren(container, {flash: true}).then(() =>
+ container.updateLevel());
+ } else if (type === "inlineTextChild") {
+ container.childrenDirty = true;
+ this._updateChildren(container, {flash: true});
+ container.update();
+ }
+ }
+
+ this._waitForChildren().then(() => {
+ if (this._destroyer) {
+ // Could not fully update after markup mutations, the markup-view was destroyed
+ // while waiting for children. Bail out silently.
+ return;
+ }
+ this._flashMutatedNodes(mutations);
+ this.inspector.emit("markupmutation", mutations);
+
+ // Since the htmlEditor is absolutely positioned, a mutation may change
+ // the location in which it should be shown.
+ this.htmlEditor.refresh();
+ });
+ },
+
+ /**
+ * React to display-change events from the walker
+ *
+ * @param {Array} nodes
+ * An array of nodeFronts
+ */
+ _onDisplayChange: function (nodes) {
+ for (let node of nodes) {
+ let container = this.getContainer(node);
+ if (container) {
+ container.updateIsDisplayed();
+ }
+ }
+ },
+
+ /**
+ * Given a list of mutations returned by the mutation observer, flash the
+ * corresponding containers to attract attention.
+ */
+ _flashMutatedNodes: function (mutations) {
+ let addedOrEditedContainers = new Set();
+ let removedContainers = new Set();
+
+ for (let {type, target, added, removed, newValue} of mutations) {
+ let container = this.getContainer(target);
+
+ if (container) {
+ if (type === "characterData") {
+ addedOrEditedContainers.add(container);
+ } else if (type === "attributes" && newValue === null) {
+ // Removed attributes should flash the entire node.
+ // New or changed attributes will flash the attribute itself
+ // in ElementEditor.flashAttribute.
+ addedOrEditedContainers.add(container);
+ } else if (type === "childList") {
+ // If there has been removals, flash the parent
+ if (removed.length) {
+ removedContainers.add(container);
+ }
+
+ // If there has been additions, flash the nodes if their associated
+ // container exist (so if their parent is expanded in the inspector).
+ added.forEach(node => {
+ let addedContainer = this.getContainer(node);
+ if (addedContainer) {
+ addedOrEditedContainers.add(addedContainer);
+
+ // The node may be added as a result of an append, in which case
+ // it will have been removed from another container first, but in
+ // these cases we don't want to flash both the removal and the
+ // addition
+ removedContainers.delete(container);
+ }
+ });
+ }
+ }
+ }
+
+ for (let container of removedContainers) {
+ container.flashMutation();
+ }
+ for (let container of addedOrEditedContainers) {
+ container.flashMutation();
+ }
+ },
+
+ /**
+ * Make sure the given node's parents are expanded and the
+ * node is scrolled on to screen.
+ */
+ showNode: function (node, centered = true) {
+ let parent = node;
+
+ this.importNode(node);
+
+ while ((parent = parent.parentNode())) {
+ this.importNode(parent);
+ this.expandNode(parent);
+ }
+
+ return this._waitForChildren().then(() => {
+ if (this._destroyer) {
+ return promise.reject("markupview destroyed");
+ }
+ return this._ensureVisible(node);
+ }).then(() => {
+ scrollIntoViewIfNeeded(this.getContainer(node).editor.elt, centered);
+ }, this._handleRejectionIfNotDestroyed);
+ },
+
+ /**
+ * Expand the container's children.
+ */
+ _expandContainer: function (container) {
+ return this._updateChildren(container, {expand: true}).then(() => {
+ if (this._destroyer) {
+ // Could not expand the node, the markup-view was destroyed in the meantime. Just
+ // silently give up.
+ return;
+ }
+ container.setExpanded(true);
+ });
+ },
+
+ /**
+ * Expand the node's children.
+ */
+ expandNode: function (node) {
+ let container = this.getContainer(node);
+ this._expandContainer(container);
+ },
+
+ /**
+ * Expand the entire tree beneath a container.
+ *
+ * @param {MarkupContainer} container
+ * The container to expand.
+ */
+ _expandAll: function (container) {
+ return this._expandContainer(container).then(() => {
+ let child = container.children.firstChild;
+ let promises = [];
+ while (child) {
+ promises.push(this._expandAll(child.container));
+ child = child.nextSibling;
+ }
+ return promise.all(promises);
+ }).then(null, console.error);
+ },
+
+ /**
+ * Expand the entire tree beneath a node.
+ *
+ * @param {DOMNode} node
+ * The node to expand, or null to start from the top.
+ */
+ expandAll: function (node) {
+ node = node || this._rootNode;
+ return this._expandAll(this.getContainer(node));
+ },
+
+ /**
+ * Collapse the node's children.
+ */
+ collapseNode: function (node) {
+ let container = this.getContainer(node);
+ container.setExpanded(false);
+ },
+
+ /**
+ * Returns either the innerHTML or the outerHTML for a remote node.
+ *
+ * @param {NodeFront} node
+ * The NodeFront to get the outerHTML / innerHTML for.
+ * @param {Boolean} isOuter
+ * If true, makes the function return the outerHTML,
+ * otherwise the innerHTML.
+ * @return {Promise} that will be resolved with the outerHTML / innerHTML.
+ */
+ _getNodeHTML: function (node, isOuter) {
+ let walkerPromise = null;
+
+ if (isOuter) {
+ walkerPromise = this.walker.outerHTML(node);
+ } else {
+ walkerPromise = this.walker.innerHTML(node);
+ }
+
+ return walkerPromise.then(longstr => {
+ return longstr.string().then(html => {
+ longstr.release().then(null, console.error);
+ return html;
+ });
+ });
+ },
+
+ /**
+ * Retrieve the outerHTML for a remote node.
+ *
+ * @param {NodeFront} node
+ * The NodeFront to get the outerHTML for.
+ * @return {Promise} that will be resolved with the outerHTML.
+ */
+ getNodeOuterHTML: function (node) {
+ return this._getNodeHTML(node, true);
+ },
+
+ /**
+ * Retrieve the innerHTML for a remote node.
+ *
+ * @param {NodeFront} node
+ * The NodeFront to get the innerHTML for.
+ * @return {Promise} that will be resolved with the innerHTML.
+ */
+ getNodeInnerHTML: function (node) {
+ return this._getNodeHTML(node);
+ },
+
+ /**
+ * Listen to mutations, expect a given node to be removed and try and select
+ * the node that sits at the same place instead.
+ * This is useful when changing the outerHTML or the tag name so that the
+ * newly inserted node gets selected instead of the one that just got removed.
+ */
+ reselectOnRemoved: function (removedNode, reason) {
+ // Only allow one removed node reselection at a time, so that when there are
+ // more than 1 request in parallel, the last one wins.
+ this.cancelReselectOnRemoved();
+
+ // Get the removedNode index in its parent node to reselect the right node.
+ let isHTMLTag = removedNode.tagName.toLowerCase() === "html";
+ let oldContainer = this.getContainer(removedNode);
+ let parentContainer = this.getContainer(removedNode.parentNode());
+ let childIndex = parentContainer.getChildContainers().indexOf(oldContainer);
+
+ let onMutations = this._removedNodeObserver = (e, mutations) => {
+ let isNodeRemovalMutation = false;
+ for (let mutation of mutations) {
+ let containsRemovedNode = mutation.removed &&
+ mutation.removed.some(n => n === removedNode);
+ if (mutation.type === "childList" &&
+ (containsRemovedNode || isHTMLTag)) {
+ isNodeRemovalMutation = true;
+ break;
+ }
+ }
+ if (!isNodeRemovalMutation) {
+ return;
+ }
+
+ this.inspector.off("markupmutation", onMutations);
+ this._removedNodeObserver = null;
+
+ // Don't select the new node if the user has already changed the current
+ // selection.
+ if (this.inspector.selection.nodeFront === parentContainer.node ||
+ (this.inspector.selection.nodeFront === removedNode && isHTMLTag)) {
+ let childContainers = parentContainer.getChildContainers();
+ if (childContainers && childContainers[childIndex]) {
+ this.markNodeAsSelected(childContainers[childIndex].node, reason);
+ if (childContainers[childIndex].hasChildren) {
+ this.expandNode(childContainers[childIndex].node);
+ }
+ this.emit("reselectedonremoved");
+ }
+ }
+ };
+
+ // Start listening for mutations until we find a childList change that has
+ // removedNode removed.
+ this.inspector.on("markupmutation", onMutations);
+ },
+
+ /**
+ * Make sure to stop listening for node removal markupmutations and not
+ * reselect the corresponding node when that happens.
+ * Useful when the outerHTML/tagname edition failed.
+ */
+ cancelReselectOnRemoved: function () {
+ if (this._removedNodeObserver) {
+ this.inspector.off("markupmutation", this._removedNodeObserver);
+ this._removedNodeObserver = null;
+ this.emit("canceledreselectonremoved");
+ }
+ },
+
+ /**
+ * Replace the outerHTML of any node displayed in the inspector with
+ * some other HTML code
+ *
+ * @param {NodeFront} node
+ * Node which outerHTML will be replaced.
+ * @param {String} newValue
+ * The new outerHTML to set on the node.
+ * @param {String} oldValue
+ * The old outerHTML that will be used if the user undoes the update.
+ * @return {Promise} that will resolve when the outer HTML has been updated.
+ */
+ updateNodeOuterHTML: function (node, newValue) {
+ let container = this.getContainer(node);
+ if (!container) {
+ return promise.reject();
+ }
+
+ // Changing the outerHTML removes the node which outerHTML was changed.
+ // Listen to this removal to reselect the right node afterwards.
+ this.reselectOnRemoved(node, "outerhtml");
+ return this.walker.setOuterHTML(node, newValue).then(null, () => {
+ this.cancelReselectOnRemoved();
+ });
+ },
+
+ /**
+ * Replace the innerHTML of any node displayed in the inspector with
+ * some other HTML code
+ * @param {Node} node
+ * node which innerHTML will be replaced.
+ * @param {String} newValue
+ * The new innerHTML to set on the node.
+ * @param {String} oldValue
+ * The old innerHTML that will be used if the user undoes the update.
+ * @return {Promise} that will resolve when the inner HTML has been updated.
+ */
+ updateNodeInnerHTML: function (node, newValue, oldValue) {
+ let container = this.getContainer(node);
+ if (!container) {
+ return promise.reject();
+ }
+
+ let def = defer();
+
+ container.undo.do(() => {
+ this.walker.setInnerHTML(node, newValue).then(def.resolve, def.reject);
+ }, () => {
+ this.walker.setInnerHTML(node, oldValue);
+ });
+
+ return def.promise;
+ },
+
+ /**
+ * Insert adjacent HTML to any node displayed in the inspector.
+ *
+ * @param {NodeFront} node
+ * The reference node.
+ * @param {String} position
+ * The position as specified for Element.insertAdjacentHTML
+ * (i.e. "beforeBegin", "afterBegin", "beforeEnd", "afterEnd").
+ * @param {String} newValue
+ * The adjacent HTML.
+ * @return {Promise} that will resolve when the adjacent HTML has
+ * been inserted.
+ */
+ insertAdjacentHTMLToNode: function (node, position, value) {
+ let container = this.getContainer(node);
+ if (!container) {
+ return promise.reject();
+ }
+
+ let def = defer();
+
+ let injectedNodes = [];
+ container.undo.do(() => {
+ this.walker.insertAdjacentHTML(node, position, value).then(nodeArray => {
+ injectedNodes = nodeArray.nodes;
+ return nodeArray;
+ }).then(def.resolve, def.reject);
+ }, () => {
+ this.walker.removeNodes(injectedNodes);
+ });
+
+ return def.promise;
+ },
+
+ /**
+ * Open an editor in the UI to allow editing of a node's outerHTML.
+ *
+ * @param {NodeFront} node
+ * The NodeFront to edit.
+ */
+ beginEditingOuterHTML: function (node) {
+ this.getNodeOuterHTML(node).then(oldValue => {
+ let container = this.getContainer(node);
+ if (!container) {
+ return;
+ }
+ this.htmlEditor.show(container.tagLine, oldValue);
+ this.htmlEditor.once("popuphidden", (e, commit, value) => {
+ // Need to focus the <html> element instead of the frame / window
+ // in order to give keyboard focus back to doc (from editor).
+ this.doc.documentElement.focus();
+
+ if (commit) {
+ this.updateNodeOuterHTML(node, value, oldValue);
+ }
+ });
+ });
+ },
+
+ /**
+ * Mark the given node expanded.
+ *
+ * @param {NodeFront} node
+ * The NodeFront to mark as expanded.
+ * @param {Boolean} expanded
+ * Whether the expand or collapse.
+ * @param {Boolean} expandDescendants
+ * Whether to expand all descendants too
+ */
+ setNodeExpanded: function (node, expanded, expandDescendants) {
+ if (expanded) {
+ if (expandDescendants) {
+ this.expandAll(node);
+ } else {
+ this.expandNode(node);
+ }
+ } else {
+ this.collapseNode(node);
+ }
+ },
+
+ /**
+ * Mark the given node selected, and update the inspector.selection
+ * object's NodeFront to keep consistent state between UI and selection.
+ *
+ * @param {NodeFront} aNode
+ * The NodeFront to mark as selected.
+ * @param {String} reason
+ * The reason for marking the node as selected.
+ * @return {Boolean} False if the node is already marked as selected, true
+ * otherwise.
+ */
+ markNodeAsSelected: function (node, reason) {
+ let container = this.getContainer(node);
+
+ if (this._selectedContainer === container) {
+ return false;
+ }
+
+ // Un-select and remove focus from the previous container.
+ if (this._selectedContainer) {
+ this._selectedContainer.selected = false;
+ this._selectedContainer.clearFocus();
+ }
+
+ // Select the new container.
+ this._selectedContainer = container;
+ if (node) {
+ this._selectedContainer.selected = true;
+ }
+
+ // Change the current selection if needed.
+ if (this.inspector.selection.nodeFront !== node) {
+ this.inspector.selection.setNodeFront(node, reason || "nodeselected");
+ }
+
+ return true;
+ },
+
+ /**
+ * Make sure that every ancestor of the selection are updated
+ * and included in the list of visible children.
+ */
+ _ensureVisible: function (node) {
+ while (node) {
+ let container = this.getContainer(node);
+ let parent = node.parentNode();
+ if (!container.elt.parentNode) {
+ let parentContainer = this.getContainer(parent);
+ if (parentContainer) {
+ parentContainer.childrenDirty = true;
+ this._updateChildren(parentContainer, {expand: true});
+ }
+ }
+
+ node = parent;
+ }
+ return this._waitForChildren();
+ },
+
+ /**
+ * Unmark selected node (no node selected).
+ */
+ unmarkSelectedNode: function () {
+ if (this._selectedContainer) {
+ this._selectedContainer.selected = false;
+ this._selectedContainer = null;
+ }
+ },
+
+ /**
+ * Check if the current selection is a descendent of the container.
+ * if so, make sure it's among the visible set for the container,
+ * and set the dirty flag if needed.
+ *
+ * @return The node that should be made visible, if any.
+ */
+ _checkSelectionVisible: function (container) {
+ let centered = null;
+ let node = this.inspector.selection.nodeFront;
+ while (node) {
+ if (node.parentNode() === container.node) {
+ centered = node;
+ break;
+ }
+ node = node.parentNode();
+ }
+
+ return centered;
+ },
+
+ /**
+ * Make sure all children of the given container's node are
+ * imported and attached to the container in the right order.
+ *
+ * Children need to be updated only in the following circumstances:
+ * a) We just imported this node and have never seen its children.
+ * container.childrenDirty will be set by importNode in this case.
+ * b) We received a childList mutation on the node.
+ * container.childrenDirty will be set in that case too.
+ * c) We have changed the selection, and the path to that selection
+ * wasn't loaded in a previous children request (because we only
+ * grab a subset).
+ * container.childrenDirty should be set in that case too!
+ *
+ * @param {MarkupContainer} container
+ * The markup container whose children need updating
+ * @param {Object} options
+ * Options are {expand:boolean,flash:boolean}
+ * @return {Promise} that will be resolved when the children are ready
+ * (which may be immediately).
+ */
+ _updateChildren: function (container, options) {
+ let expand = options && options.expand;
+ let flash = options && options.flash;
+
+ container.hasChildren = container.node.hasChildren;
+ // Accessibility should either ignore empty children or semantically
+ // consider them a group.
+ container.setChildrenRole();
+
+ if (!this._queuedChildUpdates) {
+ this._queuedChildUpdates = new Map();
+ }
+
+ if (this._queuedChildUpdates.has(container)) {
+ return this._queuedChildUpdates.get(container);
+ }
+
+ if (!container.childrenDirty) {
+ return promise.resolve(container);
+ }
+
+ if (container.inlineTextChild
+ && container.inlineTextChild != container.node.inlineTextChild) {
+ // This container was doing double duty as a container for a single
+ // text child, back that out.
+ this._containers.delete(container.inlineTextChild);
+ container.clearInlineTextChild();
+
+ if (container.hasChildren && container.selected) {
+ container.setExpanded(true);
+ }
+ }
+
+ if (container.node.inlineTextChild) {
+ container.setExpanded(false);
+ // this container will do double duty as the container for the single
+ // text child.
+ while (container.children.firstChild) {
+ container.children.removeChild(container.children.firstChild);
+ }
+
+ container.setInlineTextChild(container.node.inlineTextChild);
+
+ this._containers.set(container.node.inlineTextChild, container);
+ container.childrenDirty = false;
+ return promise.resolve(container);
+ }
+
+ if (!container.hasChildren) {
+ while (container.children.firstChild) {
+ container.children.removeChild(container.children.firstChild);
+ }
+ container.childrenDirty = false;
+ container.setExpanded(false);
+ return promise.resolve(container);
+ }
+
+ // If we're not expanded (or asked to update anyway), we're done for
+ // now. Note that this will leave the childrenDirty flag set, so when
+ // expanded we'll refresh the child list.
+ if (!(container.expanded || expand)) {
+ return promise.resolve(container);
+ }
+
+ // We're going to issue a children request, make sure it includes the
+ // centered node.
+ let centered = this._checkSelectionVisible(container);
+
+ // Children aren't updated yet, but clear the childrenDirty flag anyway.
+ // If the dirty flag is re-set while we're fetching we'll need to fetch
+ // again.
+ container.childrenDirty = false;
+ let updatePromise =
+ this._getVisibleChildren(container, centered).then(children => {
+ if (!this._containers) {
+ return promise.reject("markup view destroyed");
+ }
+ this._queuedChildUpdates.delete(container);
+
+ // If children are dirty, we got a change notification for this node
+ // while the request was in progress, we need to do it again.
+ if (container.childrenDirty) {
+ return this._updateChildren(container, {expand: centered});
+ }
+
+ let fragment = this.doc.createDocumentFragment();
+
+ for (let child of children.nodes) {
+ let childContainer = this.importNode(child, flash);
+ fragment.appendChild(childContainer.elt);
+ }
+
+ while (container.children.firstChild) {
+ container.children.removeChild(container.children.firstChild);
+ }
+
+ if (!(children.hasFirst && children.hasLast)) {
+ let nodesCount = container.node.numChildren;
+ let showAllString = PluralForm.get(nodesCount,
+ INSPECTOR_L10N.getStr("markupView.more.showAll2"));
+ let data = {
+ showing: INSPECTOR_L10N.getStr("markupView.more.showing"),
+ showAll: showAllString.replace("#1", nodesCount),
+ allButtonClick: () => {
+ container.maxChildren = -1;
+ container.childrenDirty = true;
+ this._updateChildren(container);
+ }
+ };
+
+ 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);
+ }
+ }
+
+ container.children.appendChild(fragment);
+ return container;
+ }).catch(this._handleRejectionIfNotDestroyed);
+ this._queuedChildUpdates.set(container, updatePromise);
+ return updatePromise;
+ },
+
+ _waitForChildren: function () {
+ if (!this._queuedChildUpdates) {
+ return promise.resolve(undefined);
+ }
+
+ return promise.all([...this._queuedChildUpdates.values()]);
+ },
+
+ /**
+ * Return a list of the children to display for this container.
+ */
+ _getVisibleChildren: function (container, centered) {
+ let maxChildren = container.maxChildren || this.maxChildren;
+ if (maxChildren == -1) {
+ maxChildren = undefined;
+ }
+
+ return this.walker.children(container.node, {
+ maxNodes: maxChildren,
+ center: centered
+ });
+ },
+
+ /**
+ * Tear down the markup panel.
+ */
+ destroy: function () {
+ if (this._destroyer) {
+ return this._destroyer;
+ }
+
+ this._destroyer = promise.resolve();
+
+ this._clearBriefBoxModelTimer();
+
+ this._hoveredNode = null;
+
+ this.htmlEditor.destroy();
+ this.htmlEditor = null;
+
+ this.undo.destroy();
+ this.undo = null;
+
+ this.popup.destroy();
+ this.popup = null;
+
+ this._elt.removeEventListener("click", this._onMouseClick, false);
+ this._elt.removeEventListener("mousemove", this._onMouseMove, false);
+ this._elt.removeEventListener("mouseout", this._onMouseOut, false);
+ this._elt.removeEventListener("blur", this._onBlur, true);
+ this.win.removeEventListener("mouseup", this._onMouseUp);
+ this.win.removeEventListener("copy", this._onCopy);
+ this._frame.removeEventListener("focus", this._onFocus, false);
+ this.walker.off("mutations", this._mutationObserver);
+ this.walker.off("display-change", this._onDisplayChange);
+ this.inspector.selection.off("new-node-front", this._onNewSelection);
+ this.toolbox.off("picker-node-hovered",
+ this._onToolboxPickerHover);
+
+ this._prefObserver.off(ATTR_COLLAPSE_ENABLED_PREF,
+ this._onCollapseAttributesPrefChange);
+ this._prefObserver.off(ATTR_COLLAPSE_LENGTH_PREF,
+ this._onCollapseAttributesPrefChange);
+ this._prefObserver.destroy();
+
+ this._elt = null;
+
+ for (let [, container] of this._containers) {
+ container.destroy();
+ }
+ this._containers = null;
+
+ this.eventDetailsTooltip.destroy();
+ this.eventDetailsTooltip = null;
+
+ this.imagePreviewTooltip.destroy();
+ this.imagePreviewTooltip = null;
+
+ this.win = null;
+ this.doc = null;
+
+ this._lastDropTarget = null;
+ this._lastDragTarget = null;
+
+ return this._destroyer;
+ },
+
+ /**
+ * Find the closest element with class tag-line. These are used to indicate
+ * drag and drop targets.
+ *
+ * @param {DOMNode} el
+ * @return {DOMNode}
+ */
+ findClosestDragDropTarget: function (el) {
+ return el.classList.contains("tag-line")
+ ? el
+ : el.querySelector(".tag-line") || el.closest(".tag-line");
+ },
+
+ /**
+ * Takes an element as it's only argument and marks the element
+ * as the drop target
+ */
+ indicateDropTarget: function (el) {
+ if (this._lastDropTarget) {
+ this._lastDropTarget.classList.remove("drop-target");
+ }
+
+ if (!el) {
+ return;
+ }
+
+ let target = this.findClosestDragDropTarget(el);
+ if (target) {
+ target.classList.add("drop-target");
+ this._lastDropTarget = target;
+ }
+ },
+
+ /**
+ * Takes an element to mark it as indicator of dragging target's initial place
+ */
+ indicateDragTarget: function (el) {
+ if (this._lastDragTarget) {
+ this._lastDragTarget.classList.remove("drag-target");
+ }
+
+ if (!el) {
+ return;
+ }
+
+ let target = this.findClosestDragDropTarget(el);
+ if (target) {
+ target.classList.add("drag-target");
+ this._lastDragTarget = target;
+ }
+ },
+
+ /**
+ * Used to get the nodes required to modify the markup after dragging the
+ * element (parent/nextSibling).
+ */
+ get dropTargetNodes() {
+ let target = this._lastDropTarget;
+
+ if (!target) {
+ return null;
+ }
+
+ let parent, nextSibling;
+
+ if (target.previousElementSibling &&
+ target.previousElementSibling.nodeName.toLowerCase() === "ul") {
+ parent = target.parentNode.container.node;
+ nextSibling = null;
+ } else {
+ parent = target.parentNode.container.node.parentNode();
+ nextSibling = target.parentNode.container.node;
+ }
+
+ if (nextSibling && nextSibling.isBeforePseudoElement) {
+ nextSibling = target.parentNode.parentNode.children[1].container.node;
+ }
+ if (nextSibling && nextSibling.isAfterPseudoElement) {
+ parent = target.parentNode.container.node.parentNode();
+ nextSibling = null;
+ }
+
+ if (parent.nodeType !== nodeConstants.ELEMENT_NODE) {
+ return null;
+ }
+
+ return {parent, nextSibling};
+ }
+};
+
+/**
+ * Map a number from one range to another.
+ */
+function map(value, oldMin, oldMax, newMin, newMax) {
+ let ratio = oldMax - oldMin;
+ if (ratio == 0) {
+ return value;
+ }
+ return newMin + (newMax - newMin) * ((value - oldMin) / ratio);
+}
+
+module.exports = MarkupView;