gecko-dev/devtools/client/markupview/markup-view.js

3101 lines
95 KiB
JavaScript

/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */
"use strict";
const {Cc, Cu, Ci} = require("chrome");
// Page size for pageup/pagedown
const PAGE_SIZE = 10;
const DEFAULT_MAX_CHILDREN = 100;
const COLLAPSE_DATA_URL_REGEX = /^data.+base64/;
const COLLAPSE_DATA_URL_LENGTH = 60;
const NEW_SELECTION_HIGHLIGHTER_TIMER = 1000;
const DRAG_DROP_AUTOSCROLL_EDGE_DISTANCE = 50;
const DRAG_DROP_MIN_AUTOSCROLL_SPEED = 5;
const DRAG_DROP_MAX_AUTOSCROLL_SPEED = 15;
const DRAG_DROP_MIN_INITIAL_DISTANCE = 10;
const AUTOCOMPLETE_POPUP_PANEL_ID = "markupview_autoCompletePopup";
const {UndoStack} = require("devtools/client/shared/undo");
const {editableField, InplaceEditor} = require("devtools/client/shared/inplace-editor");
const {HTMLEditor} = require("devtools/client/markupview/html-editor");
const promise = require("promise");
const {Tooltip} = require("devtools/client/shared/widgets/Tooltip");
const EventEmitter = require("devtools/shared/event-emitter");
const Heritage = require("sdk/core/heritage");
const {setTimeout, clearTimeout, setInterval, clearInterval} = require("sdk/timers");
const {parseAttribute} = require("devtools/client/shared/node-attribute-parser");
const ELLIPSIS = Services.prefs.getComplexValue("intl.ellipsis", Ci.nsIPrefLocalizedString).data;
const {Task} = require("resource://gre/modules/Task.jsm");
const {scrollIntoViewIfNeeded} = require("devtools/shared/layout/utils");
Cu.import("resource://devtools/shared/gcli/Templater.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
loader.lazyGetter(this, "DOMParser", function() {
return Cc["@mozilla.org/xmlextras/domparser;1"].createInstance(Ci.nsIDOMParser);
});
loader.lazyGetter(this, "AutocompletePopup", () => {
return require("devtools/client/shared/autocomplete-popup").AutocompletePopup;
});
/**
* 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 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.walker = this._inspector.walker;
this._frame = aFrame;
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.collapseAttributeLength =
Services.prefs.getIntPref("devtools.markup.collapseAttributeLength");
// Creating the popup to be used to show CSS suggestions.
let options = {
autoSelect: true,
theme: "auto",
// panelId option prevents the markupView autocomplete popup from
// sharing XUL elements with other views, such as ruleView (see Bug 1191093)
panelId: AUTOCOMPLETE_POPUP_PANEL_ID
};
this.popup = new AutocompletePopup(this.doc.defaultView.parent.document, options);
this.undo = new UndoStack();
this.undo.installController(aControllerWindow);
this._containers = new Map();
// Binding functions that need to be called in scope.
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._onKeyDown = this._onKeyDown.bind(this);
this._onCopy = this._onCopy.bind(this);
this._onFocus = this._onFocus.bind(this);
this._onMouseMove = this._onMouseMove.bind(this);
this._onMouseLeave = this._onMouseLeave.bind(this);
this._onToolboxPickerHover = this._onToolboxPickerHover.bind(this);
// Listening to various events.
this._elt.addEventListener("click", this._onMouseClick, false);
this._elt.addEventListener("mousemove", this._onMouseMove, false);
this._elt.addEventListener("mouseleave", this._onMouseLeave, false);
this.doc.body.addEventListener("mouseup", this._onMouseUp);
this.win.addEventListener("keydown", this._onKeyDown, false);
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._inspector.toolbox.on("picker-node-hovered", this._onToolboxPickerHover);
this._onNewSelection();
this._initTooltips();
EventEmitter.decorate(this);
}
exports.MarkupView = MarkupView;
MarkupView.prototype = {
/**
* How long does a node flash when it mutates (in ms).
*/
CONTAINER_FLASHING_DURATION: 500,
_selectedContainer: null,
_initTooltips: function() {
this.tooltip = new Tooltip(this._inspector.panelDoc);
this._makeTooltipPersistent(false);
},
_makeTooltipPersistent: function(state) {
if (state) {
this.tooltip.stopTogglingOnHover();
} else {
this.tooltip.startTogglingOnHover(this._elt,
this._isImagePreviewTarget.bind(this));
}
},
_onToolboxPickerHover: function(event, nodeFront) {
this.showNode(nodeFront).then(() => {
this._showContainerAsHovered(nodeFront);
}, e => console.error(e));
},
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) {
if (container.node.nodeType !== Ci.nsIDOMNode.TEXT_NODE) {
this._showBoxModel(container.node);
} else {
this._hideBoxModel();
}
}
this._showContainerAsHovered(container.node);
},
/**
* 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._autoScrollInterval) {
clearInterval(this._autoScrollInterval);
}
// 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;
if (fromBottom <= DRAG_DROP_AUTOSCROLL_EDGE_DISTANCE) {
// Map our distance from 0-50 to 5-15 range so the speed is kept in a
// range not too fast, not too slow.
let speed = map(
fromBottom,
0, DRAG_DROP_AUTOSCROLL_EDGE_DISTANCE,
DRAG_DROP_MIN_AUTOSCROLL_SPEED, DRAG_DROP_MAX_AUTOSCROLL_SPEED);
this._autoScrollInterval = setInterval(() => {
docEl.scrollTop -= speed - DRAG_DROP_MAX_AUTOSCROLL_SPEED;
}, 0);
}
if (fromTop <= DRAG_DROP_AUTOSCROLL_EDGE_DISTANCE) {
let speed = map(
fromTop,
0, DRAG_DROP_AUTOSCROLL_EDGE_DISTANCE,
DRAG_DROP_MIN_AUTOSCROLL_SPEED, DRAG_DROP_MAX_AUTOSCROLL_SPEED);
this._autoScrollInterval = setInterval(() => {
docEl.scrollTop += speed - DRAG_DROP_MAX_AUTOSCROLL_SPEED;
}, 0);
}
},
_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.tooltip);
}
},
_onMouseUp: function() {
this.indicateDropTarget(null);
this.indicateDragTarget(null);
if (this._autoScrollInterval) {
clearInterval(this._autoScrollInterval);
}
},
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._autoScrollInterval) {
clearInterval(this._autoScrollInterval);
}
},
_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;
},
_onMouseLeave: function() {
if (this._autoScrollInterval) {
clearInterval(this._autoScrollInterval);
}
if (this.isDragging) {
return;
}
this._hideBoxModel(true);
if (this._hoveredNode) {
this.getContainer(this._hoveredNode).hovered = false;
}
this._hoveredNode = null;
},
/**
* Show the box model highlighter on a given node front
* @param {NodeFront} nodeFront The node to show the highlighter for
* @return a promise that 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._inspector.toolbox.highlighterUtils.highlightNodeFront(nodeFront);
},
/**
* Hide the box model highlighter on a given node front
* @param {NodeFront} nodeFront The node to hide the highlighter for
* @param {Boolean} forceHide See toolbox-highlighter-utils/unhighlight
* @return a promise that 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._inspector.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 = promise.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(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(aNode) {
return this._containers.get(aNode);
},
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 the promise returned by MarkupElementContainer._isImagePreviewTarget
*/
_isImagePreviewTarget: 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 promise.reject(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.tooltip);
}
},
/**
* 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 isHighlitNode = this._hoveredNode === this._inspector.selection.nodeFront;
return !isHighlitNode && 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 receives focus so the keyboard can be used.
this.maybeFocusNewSelection();
}).catch(e => {
if (!this._destroyer) {
console.error(e);
} else {
console.warn("Could not mark node as selected, the markup-view was " +
"destroyed while showing the node.");
}
});
promise.all([onShowBoxModel, onShow]).then(done);
},
/**
* Focus the current node selection's MarkupContainer if the selection
* happened because the user picked an element using the element picker or
* browser context menu.
*/
maybeFocusNewSelection: function() {
let {reason, nodeFront} = this._inspector.selection;
if (reason !== "browser-context-menu" &&
reason !== "picker-node-picked") {
return;
}
this.getContainer(nodeFront).focus();
},
/**
* Create a TreeWalker to find the next/previous
* node for selection.
*/
_selectionWalker: function(aStart) {
let walker = this.doc.createTreeWalker(
aStart || this._elt,
Ci.nsIDOMNodeFilter.SHOW_ELEMENT,
function(aElement) {
if (aElement.container &&
aElement.container.elt === aElement &&
aElement.container.visible) {
return Ci.nsIDOMNodeFilter.FILTER_ACCEPT;
}
return Ci.nsIDOMNodeFilter.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();
},
/**
* Key handling.
*/
_onKeyDown: function(aEvent) {
let handled = true;
// Ignore keystrokes that originated in editors.
if (this._isInputOrTextarea(aEvent.target)) {
return;
}
switch(aEvent.keyCode) {
case Ci.nsIDOMKeyEvent.DOM_VK_H:
if (aEvent.metaKey || aEvent.shiftKey) {
handled = false;
} else {
let node = this._selectedContainer.node;
if (node.hidden) {
this.walker.unhideNode(node);
} else {
this.walker.hideNode(node);
}
}
break;
case Ci.nsIDOMKeyEvent.DOM_VK_DELETE:
this.deleteNodeOrAttribute();
break;
case Ci.nsIDOMKeyEvent.DOM_VK_BACK_SPACE:
this.deleteNodeOrAttribute(true);
break;
case Ci.nsIDOMKeyEvent.DOM_VK_HOME:
let rootContainer = this.getContainer(this._rootNode);
this.navigate(rootContainer.children.firstChild.container);
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._selectedContainer.hasChildren) {
this._expandContainer(this._selectedContainer);
} 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;
}
case Ci.nsIDOMKeyEvent.DOM_VK_F2: {
this.beginEditingOuterHTML(this._selectedContainer.node);
break;
}
case Ci.nsIDOMKeyEvent.DOM_VK_S: {
let selection = this._selectedContainer.node;
this._inspector.scrollNodeIntoView(selection);
break;
}
case Ci.nsIDOMKeyEvent.DOM_VK_ESCAPE: {
if (this.isDragging) {
this.cancelDragging();
break;
}
}
default:
handled = false;
}
if (handled) {
aEvent.stopPropagation();
aEvent.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} aNode The node to remove.
* @param {boolean} moveBackward If set to true, focus the previous sibling,
* otherwise the next one.
*/
deleteNode: function(aNode, moveBackward) {
if (aNode.isDocumentElement ||
aNode.nodeType == Ci.nsIDOMNode.DOCUMENT_TYPE_NODE ||
aNode.isAnonymous) {
return;
}
let container = this.getContainer(aNode);
// Retain the node so we can undo this...
this.walker.retainNode(aNode).then(() => {
let parent = aNode.parentNode();
let nextSibling = null;
this.undo.do(() => {
this.walker.removeNode(aNode).then(siblings => {
nextSibling = siblings.nextSibling;
let focusNode = moveBackward ? siblings.previousSibling : 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 || siblings.previousSibling || parent;
}
if (container.selected) {
this.navigate(this.getContainer(focusNode));
}
});
}, () => {
this.walker.insertBefore(aNode, parent, nextSibling);
});
}).then(null, console.error);
},
/**
* If an editable item is focused, select its container.
*/
_onFocus: function(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(aContainer, aIgnoreFocus) {
if (!aContainer) {
return;
}
let node = aContainer.node;
this.markNodeAsSelected(node, "treepanel");
if (!aIgnoreFocus) {
aContainer.focus();
}
},
/**
* Make sure a node is included in the markup tool.
*
* @param NodeFront aNode
* The node in the content document.
* @param boolean aFlashNode
* Whether the newly imported node should be flashed
* @returns MarkupContainer The MarkupContainer object for this element.
*/
importNode: function(aNode, aFlashNode) {
if (!aNode) {
return null;
}
if (this._containers.has(aNode)) {
return this.getContainer(aNode);
}
let container;
let {nodeType, isPseudoElement} = aNode;
if (aNode === this.walker.rootNode) {
container = new RootContainer(this, aNode);
this._elt.appendChild(container.elt);
this._rootNode = aNode;
} else if (nodeType == Ci.nsIDOMNode.ELEMENT_NODE && !isPseudoElement) {
container = new MarkupElementContainer(this, aNode, this._inspector);
} else if (nodeType == Ci.nsIDOMNode.COMMENT_NODE ||
nodeType == Ci.nsIDOMNode.TEXT_NODE) {
container = new MarkupTextContainer(this, aNode, this._inspector);
} else {
container = new MarkupReadOnlyContainer(this, aNode, this._inspector);
}
if (aFlashNode) {
container.flashMutation();
}
this._containers.set(aNode, container);
container.childrenDirty = true;
this._updateChildren(container);
this._inspector.emit("container-created", container);
return container;
},
/**
* Mutation observer used for included nodes.
*/
_mutationObserver: function(aMutations) {
for (let mutation of aMutations) {
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" || type === "characterData") {
container.update();
} else if (type === "childList" || type === "nativeAnonymousChildList") {
container.childrenDirty = true;
// Update the children to take care of changes in the markup view DOM.
this._updateChildren(container, {flash: true});
} else if (type === "pseudoClassLock") {
container.update();
}
}
this._waitForChildren().then(() => {
if (this._destroyer) {
console.warn("Could not fully update after markup mutations, " +
"the markup-view was destroyed while waiting for children.");
return;
}
this._flashMutatedNodes(aMutations);
this._inspector.emit("markupmutation", aMutations);
// 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.isDisplayed = node.isDisplayed;
}
}
},
/**
* Given a list of mutations returned by the mutation observer, flash the
* corresponding containers to attract attention.
*/
_flashMutatedNodes: function(aMutations) {
let addedOrEditedContainers = new Set();
let removedContainers = new Set();
for (let {type, target, added, removed, newValue} of aMutations) {
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(added => {
let addedContainer = this.getContainer(added);
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(aNode, centered=true) {
let parent = aNode;
this.importNode(aNode);
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(aNode);
}).then(() => {
scrollIntoViewIfNeeded(this.getContainer(aNode).editor.elt, centered);
}, e => {
// Only report this rejection as an error if the panel hasn't been
// destroyed in the meantime.
if (!this._destroyer) {
console.error(e);
} else {
console.warn("Could not show the node, the markup-view was destroyed " +
"while waiting for children");
}
});
},
/**
* Expand the container's children.
*/
_expandContainer: function(aContainer) {
return this._updateChildren(aContainer, {expand: true}).then(() => {
if (this._destroyer) {
console.warn("Could not expand the node, the markup-view was destroyed");
return;
}
aContainer.setExpanded(true);
});
},
/**
* Expand the node's children.
*/
expandNode: function(aNode) {
let container = this.getContainer(aNode);
this._expandContainer(container);
},
/**
* Expand the entire tree beneath a container.
*
* @param aContainer The container to expand.
*/
_expandAll: function(aContainer) {
return this._expandContainer(aContainer).then(() => {
let child = aContainer.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 aContainer The node to expand, or null
* to start from the top.
*/
expandAll: function(aNode) {
aNode = aNode || this._rootNode;
return this._expandAll(this.getContainer(aNode));
},
/**
* Collapse the node's children.
*/
collapseNode: function(aNode) {
let container = this.getContainer(aNode);
container.setExpanded(false);
},
/**
* Returns either the innerHTML or the outerHTML for a remote node.
* @param aNode The NodeFront to get the outerHTML / innerHTML for.
* @param isOuter A boolean that, if true, makes the function return the
* outerHTML, otherwise the innerHTML.
* @returns A promise that will be resolved with the outerHTML / innerHTML.
*/
_getNodeHTML: function(aNode, isOuter) {
let walkerPromise = null;
if (isOuter) {
walkerPromise = this.walker.outerHTML(aNode);
} else {
walkerPromise = this.walker.innerHTML(aNode);
}
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 aNode The NodeFront to get the outerHTML for.
* @returns A promise that will be resolved with the outerHTML.
*/
getNodeOuterHTML: function(aNode) {
return this._getNodeHTML(aNode, true);
},
/**
* Retrieve the innerHTML for a remote node.
* @param aNode The NodeFront to get the innerHTML for.
* @returns A promise that will be resolved with the innerHTML.
*/
getNodeInnerHTML: function(aNode) {
return this._getNodeHTML(aNode);
},
/**
* 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.
* @returns A promise that will resolve when the outer HTML has been updated.
*/
updateNodeOuterHTML: function(node, newValue, oldValue) {
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.
* @returns A 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 = promise.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.
* @returns A 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 = promise.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 aNode The NodeFront to edit.
*/
beginEditingOuterHTML: function(aNode) {
this.getNodeOuterHTML(aNode).then(oldValue => {
let container = this.getContainer(aNode);
if (!container) {
return;
}
this.htmlEditor.show(container.tagLine, oldValue);
this.htmlEditor.once("popuphidden", (e, aCommit, aValue) => {
// 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 (aCommit) {
this.updateNodeOuterHTML(aNode, aValue, oldValue);
}
});
});
},
/**
* Mark the given node expanded.
* @param {NodeFront} aNode The NodeFront to mark as expanded.
* @param {Boolean} aExpanded Whether the expand or collapse.
* @param {Boolean} aExpandDescendants Whether to expand all descendants too
*/
setNodeExpanded: function(aNode, aExpanded, aExpandDescendants) {
if (aExpanded) {
if (aExpandDescendants) {
this.expandAll(aNode);
} else {
this.expandNode(aNode);
}
} else {
this.collapseNode(aNode);
}
},
/**
* 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 the previous container.
if (this._selectedContainer) {
this._selectedContainer.selected = false;
}
// 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.
* @returns The node that should be made visible, if any.
*/
_checkSelectionVisible: function(aContainer) {
let centered = null;
let node = this._inspector.selection.nodeFront;
while (node) {
if (node.parentNode() === aContainer.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 aContainer
* The markup container whose children need updating
* @param Object options
* Options are {expand:boolean,flash:boolean}
* @return a promise that will be resolved when the children are ready
* (which may be immediately).
*/
_updateChildren: function(aContainer, options) {
let expand = options && options.expand;
let flash = options && options.flash;
aContainer.hasChildren = aContainer.node.hasChildren;
if (!this._queuedChildUpdates) {
this._queuedChildUpdates = new Map();
}
if (this._queuedChildUpdates.has(aContainer)) {
return this._queuedChildUpdates.get(aContainer);
}
if (!aContainer.childrenDirty) {
return promise.resolve(aContainer);
}
if (aContainer.singleTextChild
&& aContainer.singleTextChild != aContainer.node.singleTextChild) {
// This container was doing double duty as a container for a single
// text child, back that out.
this._containers.delete(aContainer.singleTextChild);
aContainer.clearSingleTextChild();
if (aContainer.hasChildren && aContainer.selected) {
aContainer.setExpanded(true);
}
}
if (aContainer.node.singleTextChild) {
aContainer.setExpanded(false);
// this container will do double duty as the container for the single
// text child.
while (aContainer.children.firstChild) {
aContainer.children.removeChild(aContainer.children.firstChild);
}
aContainer.setSingleTextChild(aContainer.node.singleTextChild);
this._containers.set(aContainer.node.singleTextChild, aContainer);
aContainer.childrenDirty = false;
return promise.resolve(aContainer);
}
if (!aContainer.hasChildren) {
while (aContainer.children.firstChild) {
aContainer.children.removeChild(aContainer.children.firstChild);
}
aContainer.childrenDirty = false;
aContainer.setExpanded(false);
return promise.resolve(aContainer);
}
// 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 (!(aContainer.expanded || expand)) {
return promise.resolve(aContainer);
}
// We're going to issue a children request, make sure it includes the
// centered node.
let centered = this._checkSelectionVisible(aContainer);
// 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.
aContainer.childrenDirty = false;
let updatePromise = this._getVisibleChildren(aContainer, centered).then(children => {
if (!this._containers) {
return promise.reject("markup view destroyed");
}
this._queuedChildUpdates.delete(aContainer);
// 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 (aContainer.childrenDirty) {
return this._updateChildren(aContainer, {expand: centered});
}
let fragment = this.doc.createDocumentFragment();
for (let child of children.nodes) {
let container = this.importNode(child, flash);
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.numChildren.toString()], 1),
allButtonClick: () => {
aContainer.maxChildren = -1;
aContainer.childrenDirty = true;
this._updateChildren(aContainer);
}
};
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 aContainer;
}).then(null, console.error);
this._queuedChildUpdates.set(aContainer, 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(aContainer, aCentered) {
let maxChildren = aContainer.maxChildren || this.maxChildren;
if (maxChildren == -1) {
maxChildren = undefined;
}
return this.walker.children(aContainer.node, {
maxNodes: maxChildren,
center: aCentered
});
},
/**
* 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("mouseleave", this._onMouseLeave, false);
this.doc.body.removeEventListener("mouseup", this._onMouseUp);
this.win.removeEventListener("keydown", this._onKeyDown, false);
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._inspector.toolbox.off("picker-node-hovered", this._onToolboxPickerHover);
this._elt = null;
for (let [, container] of this._containers) {
container.destroy();
}
this._containers = null;
this.tooltip.destroy();
this.tooltip = 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 (this._lastDropTarget.previousElementSibling &&
this._lastDropTarget.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 !== Ci.nsIDOMNode.ELEMENT_NODE) {
return null;
}
return {parent, nextSibling};
}
};
/**
* 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.
*
* This should not be instantiated directly, instead use one of:
* MarkupReadOnlyContainer
* MarkupTextContainer
* MarkupElementContainer
*/
function MarkupContainer() { }
MarkupContainer.prototype = {
/*
* Initialize the MarkupContainer. Should be called while one
* of the other contain classes is instantiated.
*
* @param MarkupView markupView
* The markup view that owns this container.
* @param NodeFront node
* The node to display.
* @param string templateID
* Which template to render for this container
*/
initialize: function(markupView, node, templateID) {
this.markup = markupView;
this.node = node;
this.undo = this.markup.undo;
this.win = this.markup._frame.contentWindow;
// The template will fill the following properties
this.elt = null;
this.expander = null;
this.tagState = null;
this.tagLine = null;
this.children = null;
this.markup.template(templateID, this);
this.elt.container = this;
this._onMouseDown = this._onMouseDown.bind(this);
this._onToggle = this._onToggle.bind(this);
this._onMouseUp = this._onMouseUp.bind(this);
this._onMouseMove = this._onMouseMove.bind(this);
// Binding event listeners
this.elt.addEventListener("mousedown", this._onMouseDown, false);
this.markup.doc.body.addEventListener("mouseup", this._onMouseUp, true);
this.markup.doc.body.addEventListener("mousemove", this._onMouseMove, true);
this.elt.addEventListener("dblclick", this._onToggle, false);
if (this.expander) {
this.expander.addEventListener("click", this._onToggle, false);
}
// Marking the node as shown or hidden
this.isDisplayed = this.node.isDisplayed;
},
toString: function() {
return "[MarkupContainer for " + this.node + "]";
},
isPreviewable: function() {
if (this.node.tagName && !this.node.isPseudoElement) {
let tagName = this.node.tagName.toLowerCase();
let srcAttr = this.editor.getAttributeElement("src");
let isImage = tagName === "img" && srcAttr;
let isCanvas = tagName === "canvas";
return isImage || isCanvas;
}
return false;
},
/**
* Show the element has displayed or not
*/
set isDisplayed(isDisplayed) {
this.elt.classList.remove("not-displayed");
if (!isDisplayed) {
this.elt.classList.add("not-displayed");
}
},
/**
* 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;
this.updateExpander();
},
/**
* True if the current node can be expanded.
*/
get canExpand() {
return this._hasChildren && !this.node.singleTextChild;
},
/**
* True if this is the root <html> element and can't be collapsed
*/
get mustExpand() {
return this.node._parent === this.markup.walker.rootNode;
},
updateExpander: function() {
if (!this.expander) {
return;
}
if (this.canExpand && !this.mustExpand) {
this.expander.style.visibility = "visible";
} else {
this.expander.style.visibility = "hidden";
}
},
/**
* If the node has children, return the list of containers for all these
* children.
*/
getChildContainers: function() {
if (!this.hasChildren) {
return null;
}
return [...this.children.children].map(node => node.container);
},
/**
* True if the node has been visually expanded in the tree.
*/
get expanded() {
return !this.elt.classList.contains("collapsed");
},
setExpanded: function(aValue) {
if (!this.expander) {
return;
}
if (!this.canExpand) {
aValue = false;
}
if (this.mustExpand) {
aValue = true;
}
if (aValue && this.elt.classList.contains("collapsed")) {
// Expanding a node means cloning its "inline" closing tag into a new
// tag-line that the user can interact with and showing the children.
let closingTag = this.elt.querySelector(".close");
if (closingTag) {
if (!this.closeTagLine) {
let line = this.markup.doc.createElement("div");
line.classList.add("tag-line");
let tagState = this.markup.doc.createElement("div");
tagState.classList.add("tag-state");
line.appendChild(tagState);
line.appendChild(closingTag.cloneNode(true));
flashElementOff(line);
this.closeTagLine = line;
}
this.elt.appendChild(this.closeTagLine);
}
this.elt.classList.remove("collapsed");
this.expander.setAttribute("open", "");
this.hovered = false;
} else if (!aValue) {
if (this.closeTagLine) {
this.elt.removeChild(this.closeTagLine);
this.closeTagLine = undefined;
}
this.elt.classList.add("collapsed");
this.expander.removeAttribute("open");
}
},
parentContainer: function() {
return this.elt.parentNode ? this.elt.parentNode.container : null;
},
_isDragging: false,
_dragStartY: 0,
set isDragging(isDragging) {
this._isDragging = isDragging;
this.markup.isDragging = isDragging;
if (isDragging) {
this.elt.classList.add("dragging");
this.markup.doc.body.classList.add("dragging");
} else {
this.elt.classList.remove("dragging");
this.markup.doc.body.classList.remove("dragging");
}
},
get isDragging() {
return this._isDragging;
},
/**
* Check if element is draggable
*/
isDraggable: function() {
let tagName = this.node.tagName.toLowerCase();
return !this.node.isPseudoElement &&
!this.node.isAnonymous &&
!this.node.isDocumentElement &&
tagName !== "body" &&
tagName !== "head" &&
this.win.getSelection().isCollapsed &&
this.node.parentNode().tagName !== null;
},
_onMouseDown: function(event) {
let {target, button, metaKey, ctrlKey} = event;
let isLeftClick = button === 0;
let isMiddleClick = button === 1;
let isMetaClick = isLeftClick && (metaKey || ctrlKey);
// The "show more nodes" button already has its onclick, so early return.
if (target.nodeName === "button") {
return;
}
// target is the MarkupContainer itself.
this.hovered = false;
this.markup.navigate(this);
event.stopPropagation();
// Preventing the default behavior will avoid the body to gain focus on
// mouseup (through bubbling) when clicking on a non focusable node in the
// line. So, if the click happened outside of a focusable element, do
// prevent the default behavior, so that the tagname or textcontent gains
// focus.
if (!target.closest(".editor [tabindex]")) {
event.preventDefault();
}
// Follow attribute links if middle or meta click.
if (isMiddleClick || isMetaClick) {
let link = target.dataset.link;
let type = target.dataset.type;
this.markup._inspector.followAttributeLink(type, link);
return;
}
// Start node drag & drop (if the mouse moved, see _onMouseMove).
if (isLeftClick && this.isDraggable()) {
this._isPreDragging = true;
this._dragStartY = event.pageY;
}
},
/**
* On mouse up, stop dragging.
*/
_onMouseUp: Task.async(function*() {
this._isPreDragging = false;
if (this.isDragging) {
this.cancelDragging();
let dropTargetNodes = this.markup.dropTargetNodes;
if (!dropTargetNodes) {
return;
}
yield this.markup.walker.insertBefore(this.node, dropTargetNodes.parent,
dropTargetNodes.nextSibling);
this.markup.emit("drop-completed");
}
}),
/**
* On mouse move, move the dragged element and indicate the drop target.
*/
_onMouseMove: function(event) {
// If this is the first move after mousedown, only start dragging after the
// mouse has travelled a few pixels and then indicate the start position.
let initialDiff = Math.abs(event.pageY - this._dragStartY);
if (this._isPreDragging && initialDiff >= DRAG_DROP_MIN_INITIAL_DISTANCE) {
this._isPreDragging = false;
this.isDragging = true;
// If this is the last child, use the closing <div.tag-line> of parent as
// indicator.
let position = this.elt.nextElementSibling ||
this.markup.getContainer(this.node.parentNode())
.closeTagLine;
this.markup.indicateDragTarget(position);
}
if (this.isDragging) {
let diff = event.pageY - this._dragStartY;
this.elt.style.top = diff + "px";
let el = this.markup.doc.elementFromPoint(event.pageX - this.win.scrollX,
event.pageY - this.win.scrollY);
this.markup.indicateDropTarget(el);
}
},
cancelDragging: function() {
if (!this.isDragging) {
return;
}
this._isPreDragging = false;
this.isDragging = false;
this.elt.style.removeProperty("top");
},
/**
* Temporarily flash the container to attract attention.
* Used for markup mutations.
*/
flashMutation: function() {
if (!this.selected) {
let contentWin = this.win;
flashElementOn(this.tagState, this.editor.elt);
if (this._flashMutationTimer) {
clearTimeout(this._flashMutationTimer);
this._flashMutationTimer = null;
}
this._flashMutationTimer = setTimeout(() => {
flashElementOff(this.tagState, this.editor.elt);
}, this.markup.CONTAINER_FLASHING_DURATION);
}
},
_hovered: false,
/**
* Highlight the currently hovered tag + its closing tag if necessary
* (that is if the tag is expanded)
*/
set hovered(aValue) {
this.tagState.classList.remove("flash-out");
this._hovered = aValue;
if (aValue) {
if (!this.selected) {
this.tagState.classList.add("theme-bg-darker");
}
if (this.closeTagLine) {
this.closeTagLine.querySelector(".tag-state").classList.add(
"theme-bg-darker");
}
} else {
this.tagState.classList.remove("theme-bg-darker");
if (this.closeTagLine) {
this.closeTagLine.querySelector(".tag-state").classList.remove(
"theme-bg-darker");
}
}
},
/**
* 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.tagState.classList.remove("flash-out");
this._selected = aValue;
this.editor.selected = aValue;
if (this._selected) {
this.tagLine.setAttribute("selected", "");
this.tagState.classList.add("theme-selected");
} else {
this.tagLine.removeAttribute("selected");
this.tagState.classList.remove("theme-selected");
}
},
/**
* Update the container's editor to the current state of the
* viewed node.
*/
update: function() {
if (this.node.pseudoClassLocks.length) {
this.elt.classList.add("pseudoclass-locked");
} else {
this.elt.classList.remove("pseudoclass-locked");
}
if (this.editor.update) {
this.editor.update();
}
},
/**
* Try to put keyboard focus on the current editor.
*/
focus: function() {
let focusable = this.editor.elt.querySelector("[tabindex]");
if (focusable) {
focusable.focus();
}
},
_onToggle: function(event) {
this.markup.navigate(this);
if (this.hasChildren) {
this.markup.setNodeExpanded(this.node, !this.expanded, event.altKey);
}
event.stopPropagation();
},
/**
* Get rid of event listeners and references, when the container is no longer
* needed
*/
destroy: function() {
// Remove event listeners
this.elt.removeEventListener("mousedown", this._onMouseDown, false);
this.elt.removeEventListener("dblclick", this._onToggle, false);
this.markup.doc.body.removeEventListener("mouseup", this._onMouseUp, true);
this.markup.doc.body.removeEventListener("mousemove", this._onMouseMove, true);
this.win = null;
if (this.expander) {
this.expander.removeEventListener("click", this._onToggle, false);
}
// Recursively destroy children containers
let firstChild;
while (firstChild = this.children.firstChild) {
// Not all children of a container are containers themselves
// ("show more nodes" button is one example)
if (firstChild.container) {
firstChild.container.destroy();
}
this.children.removeChild(firstChild);
}
this.editor.destroy();
}
};
/**
* An implementation of MarkupContainer for Pseudo Elements,
* Doctype nodes, or any other type generic node that doesn't
* fit for other editors.
* Does not allow any editing, just viewing / selecting.
*
* @param MarkupView markupView
* The markup view that owns this container.
* @param NodeFront node
* The node to display.
*/
function MarkupReadOnlyContainer(markupView, node) {
MarkupContainer.prototype.initialize.call(this, markupView, node, "readonlycontainer");
this.editor = new GenericEditor(this, node);
this.tagLine.appendChild(this.editor.elt);
}
MarkupReadOnlyContainer.prototype = Heritage.extend(MarkupContainer.prototype, {});
/**
* An implementation of MarkupContainer for text node and comment nodes.
* Allows basic text editing in a textarea.
*
* @param MarkupView aMarkupView
* The markup view that owns this container.
* @param NodeFront aNode
* The node to display.
* @param Inspector aInspector
* The inspector tool container the markup-view
*/
function MarkupTextContainer(markupView, node) {
MarkupContainer.prototype.initialize.call(this, markupView, node, "textcontainer");
if (node.nodeType == Ci.nsIDOMNode.TEXT_NODE) {
this.editor = new TextEditor(this, node, "text");
} else if (node.nodeType == Ci.nsIDOMNode.COMMENT_NODE) {
this.editor = new TextEditor(this, node, "comment");
} else {
throw "Invalid node for MarkupTextContainer";
}
this.tagLine.appendChild(this.editor.elt);
}
MarkupTextContainer.prototype = Heritage.extend(MarkupContainer.prototype, {});
/**
* An implementation of MarkupContainer for Elements that can contain
* child nodes.
* Allows editing of tag name, attributes, expanding / collapsing.
*
* @param MarkupView markupView
* The markup view that owns this container.
* @param NodeFront node
* The node to display.
*/
function MarkupElementContainer(markupView, node) {
MarkupContainer.prototype.initialize.call(this, markupView, node, "elementcontainer");
if (node.nodeType === Ci.nsIDOMNode.ELEMENT_NODE) {
this.editor = new ElementEditor(this, node);
} else {
throw "Invalid node for MarkupElementContainer";
}
this.tagLine.appendChild(this.editor.elt);
}
MarkupElementContainer.prototype = Heritage.extend(MarkupContainer.prototype, {
_buildEventTooltipContent: function(target, tooltip) {
if (target.hasAttribute("data-event")) {
tooltip.hide(target);
this.node.getEventListenerInfo().then(listenerInfo => {
tooltip.setEventContent({
eventListenerInfos: listenerInfo,
toolbox: this.markup._inspector.toolbox
});
this.markup._makeTooltipPersistent(true);
tooltip.once("hidden", () => {
this.markup._makeTooltipPersistent(false);
});
tooltip.show(target);
});
return true;
}
},
/**
* Generates the an image preview for this Element. The element must be an
* image or canvas (@see isPreviewable).
*
* @return A Promise that is resolved with an object of form
* { data, size: { naturalWidth, naturalHeight, resizeRatio } } where
* - data is the data-uri for the image preview.
* - size contains information about the original image size and if the
* preview has been resized.
*
* If this element is not previewable or the preview cannot be generated for
* some reason, the Promise is rejected.
*/
_getPreview: function() {
if (!this.isPreviewable()) {
return promise.reject("_getPreview called on a non-previewable element.");
}
if (this.tooltipDataPromise) {
// A preview request is already pending. Re-use that request.
return this.tooltipDataPromise;
}
let maxDim =
Services.prefs.getIntPref("devtools.inspector.imagePreviewTooltipSize");
// Fetch the preview from the server.
this.tooltipDataPromise = Task.spawn(function*() {
let preview = yield this.node.getImageData(maxDim);
let data = yield preview.data.string();
// Clear the pending preview request. We can't reuse the results later as
// the preview contents might have changed.
this.tooltipDataPromise = null;
return { data, size: preview.size };
}.bind(this));
return this.tooltipDataPromise;
},
/**
* Executed by MarkupView._isImagePreviewTarget which is itself called when the
* mouse hovers over a target in the markup-view.
* Checks if the target is indeed something we want to have an image tooltip
* preview over and, if so, inserts content into the tooltip.
* @return a promise that resolves when the content has been inserted or
* rejects if no preview is required. This promise is then used by Tooltip.js
* to decide if/when to show the tooltip
*/
isImagePreviewTarget: function(target, tooltip) {
// Is this Element previewable.
if (!this.isPreviewable()) {
return promise.reject(false);
}
// If the Element has an src attribute, the tooltip is shown when hovering
// over the src url. If not, the tooltip is shown when hovering over the tag
// name.
let src = this.editor.getAttributeElement("src");
let expectedTarget = src ? src.querySelector(".link") : this.editor.tag;
if (target !== expectedTarget) {
return promise.reject(false);
}
return this._getPreview().then(({data, size}) => {
// The preview is ready.
tooltip.setImageContent(data, size);
}, () => {
// Indicate the failure but show the tooltip anyway.
tooltip.setBrokenImageContent();
});
},
copyImageDataUri: function() {
// We need to send again a request to gettooltipData even if one was sent for
// the tooltip, because we want the full-size image
this.node.getImageData().then(data => {
data.data.string().then(str => {
clipboardHelper.copyString(str);
});
});
},
setSingleTextChild: function(singleTextChild) {
this.singleTextChild = singleTextChild;
this.editor.updateTextEditor();
},
clearSingleTextChild: function() {
this.singleTextChild = undefined;
this.editor.updateTextEditor();
},
/**
* Trigger new attribute field for input.
*/
addAttribute: function() {
this.editor.newAttr.editMode();
},
/**
* Trigger attribute field for editing.
*/
editAttribute: function(attrName) {
this.editor.attrElements.get(attrName).editMode();
},
/**
* Remove attribute from container.
* This is an undoable action.
*/
removeAttribute: function(attrName) {
let doMods = this.editor._startModifyingAttributes();
let undoMods = this.editor._startModifyingAttributes();
this.editor._saveAttribute(attrName, undoMods);
doMods.removeAttribute(attrName);
this.undo.do(() => {
doMods.apply();
}, () => {
undoMods.apply();
});
}
});
/**
* Dummy container node used for the root document element.
*/
function RootContainer(aMarkupView, aNode) {
this.doc = aMarkupView.doc;
this.elt = this.doc.createElement("ul");
this.elt.container = this;
this.children = this.elt;
this.node = aNode;
this.toString = () => "[root container]";
}
RootContainer.prototype = {
hasChildren: true,
expanded: true,
update: function() {},
destroy: function() {},
/**
* If the node has children, return the list of containers for all these
* children.
*/
getChildContainers: function() {
return [...this.children.children].map(node => node.container);
},
setExpanded: function(aValue) {}
};
/**
* Creates an editor for non-editable nodes.
*/
function GenericEditor(aContainer, aNode) {
this.container = aContainer;
this.markup = this.container.markup;
this.template = this.markup.template.bind(this.markup);
this.elt = null;
this.template("generic", this);
if (aNode.isPseudoElement) {
this.tag.classList.add("theme-fg-color5");
this.tag.textContent = aNode.isBeforePseudoElement ? "::before" : "::after";
} else if (aNode.nodeType == Ci.nsIDOMNode.DOCUMENT_TYPE_NODE) {
this.elt.classList.add("comment");
this.tag.textContent = aNode.doctypeString;
} else {
this.tag.textContent = aNode.nodeName;
}
}
GenericEditor.prototype = {
destroy: function() {
this.elt.remove();
},
/**
* Stub method for consistency with ElementEditor.
*/
getInfoAtNode: function() {
return null;
}
};
/**
* 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.container = aContainer;
this.markup = this.container.markup;
this.node = aNode;
this.template = this.markup.template.bind(aTemplate);
this._selected = false;
this.markup.template(aTemplate, this);
editableField({
element: this.value,
stopOnReturn: true,
trigger: "dblclick",
multiline: true,
trimOutput: false,
done: (aVal, aCommit) => {
if (!aCommit) {
return;
}
this.node.getNodeValue().then(longstr => {
longstr.string().then(oldValue => {
longstr.release().then(null, console.error);
this.container.undo.do(() => {
this.node.setNodeValue(aVal);
}, () => {
this.node.setNodeValue(oldValue);
});
});
});
}
});
this.update();
}
TextEditor.prototype = {
get selected() {
return this._selected;
},
set selected(aValue) {
if (aValue === this._selected) {
return;
}
this._selected = aValue;
this.update();
},
update: function() {
if (!this.selected || !this.node.incompleteValue) {
let text = this.node.shortValue;
if (this.node.incompleteValue) {
text += ELLIPSIS;
}
this.value.textContent = text;
} else {
let longstr = null;
this.node.getNodeValue().then(ret => {
longstr = ret;
return longstr.string();
}).then(str => {
longstr.release().then(null, console.error);
if (this.selected) {
this.value.textContent = str;
this.markup.emit("text-expand")
}
}).then(null, console.error);
}
},
destroy: function() {},
/**
* Stub method for consistency with ElementEditor.
*/
getInfoAtNode: function() {
return null;
}
};
/**
* 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.container = aContainer;
this.node = aNode;
this.markup = this.container.markup;
this.template = this.markup.template.bind(this.markup);
this.doc = this.markup.doc;
this.attrElements = new Map();
this.animationTimers = {};
// The templates will fill the following properties
this.elt = null;
this.tag = null;
this.closeTag = null;
this.attrList = null;
this.newAttr = null;
this.closeElt = null;
// Create the main editor
this.template("element", this);
// Make the tag name editable (unless this is a remote node or
// a document element)
if (!aNode.isDocumentElement) {
this.tag.setAttribute("tabindex", "0");
editableField({
element: this.tag,
trigger: "dblclick",
stopOnReturn: true,
done: this.onTagEdit.bind(this),
});
}
// Make the new attribute space editable.
this.newAttr.editMode = editableField({
element: this.newAttr,
trigger: "dblclick",
stopOnReturn: true,
contentType: InplaceEditor.CONTENT_TYPES.CSS_MIXED,
popup: this.markup.popup,
done: (aVal, aCommit) => {
if (!aCommit) {
return;
}
let doMods = this._startModifyingAttributes();
let undoMods = this._startModifyingAttributes();
this._applyAttributes(aVal, null, doMods, undoMods);
this.container.undo.do(() => {
doMods.apply();
}, function() {
undoMods.apply();
});
}
});
let tagName = this.node.nodeName.toLowerCase();
this.tag.textContent = tagName;
this.closeTag.textContent = tagName;
this.eventNode.style.display = this.node.hasEventListeners ? "inline-block" : "none";
this.update();
this.initialized = true;
}
ElementEditor.prototype = {
set selected(aValue) {
if (this.textEditor) {
this.textEditor.selected = aValue;
}
},
flashAttribute: function(attrName) {
if (this.animationTimers[attrName]) {
clearTimeout(this.animationTimers[attrName]);
}
flashElementOn(this.getAttributeElement(attrName));
this.animationTimers[attrName] = setTimeout(() => {
flashElementOff(this.getAttributeElement(attrName));
}, this.markup.CONTAINER_FLASHING_DURATION);
},
/**
* Returns information about node in the editor.
*
* @param {DOMNode} node
* The node to get information from.
*
* @return {Object}
* An object literal with the following information:
* {type: "attribute", name: "rel", value: "index", el: node}
*/
getInfoAtNode: function(node) {
if (!node) {
return null;
}
let type = null;
let name = null;
let value = null;
// Attribute
let attribute = node.closest('.attreditor');
if (attribute) {
type = "attribute";
name = attribute.querySelector('.attr-name').textContent;
value = attribute.querySelector('.attr-value').textContent;
}
return {type, name, value, el: node};
},
/**
* Update the state of the editor from the node.
*/
update: function() {
let nodeAttributes = this.node.attributes || [];
// Keep the data model in sync with attributes on the node.
let currentAttributes = new Set(nodeAttributes.map(a=>a.name));
for (let name of this.attrElements.keys()) {
if (!currentAttributes.has(name)) {
this.removeAttribute(name);
}
}
// Only loop through the current attributes on the node. Missing
// attributes have already been removed at this point.
for (let attr of nodeAttributes) {
let el = this.attrElements.get(attr.name);
let valueChanged = el && el.querySelector(".attr-value").textContent !== attr.value;
let isEditing = el && el.querySelector(".editable").inplaceEditor;
let canSimplyShowEditor = el && (!valueChanged || isEditing);
if (canSimplyShowEditor) {
// Element already exists and doesn't need to be recreated.
// Just show it (it's hidden by default due to the template).
el.style.removeProperty("display");
} else {
// Create a new editor, because the value of an existing attribute
// has changed.
let attribute = this._createAttribute(attr, el);
attribute.style.removeProperty("display");
// Temporarily flash the attribute to highlight the change.
// But not if this is the first time the editor instance has
// been created.
if (this.initialized) {
this.flashAttribute(attr.name);
}
}
}
this.updateTextEditor();
},
/**
* Update the inline text editor in case of a single text child node.
*/
updateTextEditor: function() {
let node = this.node.singleTextChild;
if (this.textEditor && this.textEditor.node != node) {
this.elt.removeChild(this.textEditor.elt);
this.textEditor = null;
}
if (node && !this.textEditor) {
// Create a text editor added to this editor.
// This editor won't receive an update automatically, so we rely on
// child text editors to let us know that we need updating.
this.textEditor = new TextEditor(this.container, node, "text");
this.elt.insertBefore(this.textEditor.elt,
this.elt.firstChild.nextSibling.nextSibling);
}
if (this.textEditor) {
this.textEditor.update();
}
},
_startModifyingAttributes: function() {
return this.node.startModifyingAttributes();
},
/**
* Get the element used for one of the attributes of this element
* @param string attrName The name of the attribute to get the element for
* @return DOMElement
*/
getAttributeElement: function(attrName) {
return this.attrList.querySelector(
".attreditor[data-attr=" + attrName + "] .attr-value");
},
/**
* Remove an attribute from the attrElements object and the DOM
* @param string attrName The name of the attribute to remove
*/
removeAttribute: function(attrName) {
let attr = this.attrElements.get(attrName);
if (attr) {
this.attrElements.delete(attrName);
attr.remove();
}
},
_createAttribute: function(aAttr, aBefore = null) {
// Create the template editor, which will save some variables here.
let data = {
attrName: aAttr.name,
};
this.template("attribute", data);
let {attr, inner, name, val} = data;
// Double quotes need to be handled specially to prevent DOMParser failing.
// name="v"a"l"u"e" when editing -> name='v"a"l"u"e"'
// name="v'a"l'u"e" when editing -> name="v'a&quot;l'u&quot;e"
let editValueDisplayed = aAttr.value || "";
let hasDoubleQuote = editValueDisplayed.includes('"');
let hasSingleQuote = editValueDisplayed.includes("'");
let initial = aAttr.name + '="' + editValueDisplayed + '"';
// Can't just wrap value with ' since the value contains both " and '.
if (hasDoubleQuote && hasSingleQuote) {
editValueDisplayed = editValueDisplayed.replace(/\"/g, "&quot;");
initial = aAttr.name + '="' + editValueDisplayed + '"';
}
// Wrap with ' since there are no single quotes in the attribute value.
if (hasDoubleQuote && !hasSingleQuote) {
initial = aAttr.name + "='" + editValueDisplayed + "'";
}
// Make the attribute editable.
attr.editMode = editableField({
element: inner,
trigger: "dblclick",
stopOnReturn: true,
selectAll: false,
initial: initial,
contentType: InplaceEditor.CONTENT_TYPES.CSS_MIXED,
popup: this.markup.popup,
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.closest(".attr-value") === val) {
let length = editValueDisplayed.length;
let editorLength = aEditor.input.value.length;
let start = editorLength - (length + 1);
aEditor.input.setSelectionRange(start, start + length);
} else {
aEditor.input.select();
}
},
done: (aVal, aCommit, direction) => {
if (!aCommit || aVal === initial) {
return;
}
let doMods = this._startModifyingAttributes();
let undoMods = this._startModifyingAttributes();
// 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.refocusOnEdit(aAttr.name, attr, direction);
this._saveAttribute(aAttr.name, undoMods);
doMods.removeAttribute(aAttr.name);
this._applyAttributes(aVal, attr, doMods, undoMods);
this.container.undo.do(() => {
doMods.apply();
}, () => {
undoMods.apply();
});
}
});
// Figure out where we should place the attribute.
let before = aBefore;
if (aAttr.name == "id") {
before = this.attrList.firstChild;
} else if (aAttr.name == "class") {
let idNode = this.attrElements.get("id");
before = idNode ? idNode.nextSibling : this.attrList.firstChild;
}
this.attrList.insertBefore(attr, before);
this.removeAttribute(aAttr.name);
this.attrElements.set(aAttr.name, attr);
// Parse the attribute value to detect whether there are linkable parts in
// it (make sure to pass a complete list of existing attributes to the
// parseAttribute function, by concatenating aAttr, because this could be a
// newly added attribute not yet on this.node).
let attributes = this.node.attributes.filter(({name}) => name !== aAttr.name);
attributes.push(aAttr);
let parsedLinksData = parseAttribute(this.node.namespaceURI,
this.node.tagName, attributes, aAttr.name);
// Create links in the attribute value, and collapse long attributes if
// needed.
let collapse = value => {
if (value && value.match(COLLAPSE_DATA_URL_REGEX)) {
return truncateString(value, COLLAPSE_DATA_URL_LENGTH);
}
return this.markup.collapseAttributeLength < 0
? value :
truncateString(value, this.markup.collapseAttributeLength);
};
val.innerHTML = "";
for (let token of parsedLinksData) {
if (token.type === "string") {
val.appendChild(this.doc.createTextNode(collapse(token.value)));
} else {
let link = this.doc.createElement("span");
link.classList.add("link");
link.setAttribute("data-type", token.type);
link.setAttribute("data-link", token.value);
link.textContent = collapse(token.value);
val.appendChild(link);
}
}
name.textContent = aAttr.name;
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(aValue, aAttrNode, aDoMods, aUndoMods) {
let attrs = parseAttributeValues(aValue, this.doc);
for (let attr of attrs) {
// Create an attribute editor next to the current attribute if needed.
this._createAttribute(attr, aAttrNode ? aAttrNode.nextSibling : null);
this._saveAttribute(attr.name, aUndoMods);
aDoMods.setAttribute(attr.name, attr.value);
}
},
/**
* Saves the current state of the given attribute into an attribute
* modification list.
*/
_saveAttribute: function(aName, aUndoMods) {
let node = this.node;
if (node.hasAttribute(aName)) {
let oldValue = node.getAttribute(aName);
aUndoMods.setAttribute(aName, oldValue);
} else {
aUndoMods.removeAttribute(aName);
}
},
/**
* Listen to mutations, and when the attribute list is regenerated
* try to focus on the attribute after the one that's being edited now.
* If the attribute order changes, go to the beginning of the attribute list.
*/
refocusOnEdit: function(attrName, attrNode, direction) {
// Only allow one refocus on attribute change at a time, so when there's
// more than 1 request in parallel, the last one wins.
if (this._editedAttributeObserver) {
this.markup._inspector.off("markupmutation", this._editedAttributeObserver);
this._editedAttributeObserver = null;
}
let container = this.markup.getContainer(this.node);
let activeAttrs = [...this.attrList.childNodes].filter(el => el.style.display != "none");
let attributeIndex = activeAttrs.indexOf(attrNode);
let onMutations = this._editedAttributeObserver = (e, mutations) => {
let isDeletedAttribute = false;
let isNewAttribute = false;
for (let mutation of mutations) {
let inContainer = this.markup.getContainer(mutation.target) === container;
if (!inContainer) {
continue;
}
let isOriginalAttribute = mutation.attributeName === attrName;
isDeletedAttribute = isDeletedAttribute || isOriginalAttribute && mutation.newValue === null;
isNewAttribute = isNewAttribute || mutation.attributeName !== attrName;
}
let isModifiedOrder = isDeletedAttribute && isNewAttribute;
this._editedAttributeObserver = null;
// "Deleted" attributes are merely hidden, so filter them out.
let visibleAttrs = [...this.attrList.childNodes].filter(el => el.style.display != "none");
let activeEditor;
if (visibleAttrs.length > 0) {
if (!direction) {
// No direction was given; stay on current attribute.
activeEditor = visibleAttrs[attributeIndex];
} else if (isModifiedOrder) {
// The attribute was renamed, reordering the existing attributes.
// So let's go to the beginning of the attribute list for consistency.
activeEditor = visibleAttrs[0];
} else {
let newAttributeIndex;
if (isDeletedAttribute) {
newAttributeIndex = attributeIndex;
} else {
if (direction == Ci.nsIFocusManager.MOVEFOCUS_FORWARD) {
newAttributeIndex = attributeIndex + 1;
} else if (direction == Ci.nsIFocusManager.MOVEFOCUS_BACKWARD) {
newAttributeIndex = attributeIndex - 1;
}
}
// The number of attributes changed (deleted), or we moved through the array
// so check we're still within bounds.
if (newAttributeIndex >= 0 && newAttributeIndex <= visibleAttrs.length - 1) {
activeEditor = visibleAttrs[newAttributeIndex];
}
}
}
// Either we have no attributes left,
// or we just edited the last attribute and want to move on.
if (!activeEditor) {
activeEditor = this.newAttr;
}
// Refocus was triggered by tab or shift-tab.
// Continue in edit mode.
if (direction) {
activeEditor.editMode();
} else {
// Refocus was triggered by enter.
// Exit edit mode (but restore focus).
let editable = activeEditor === this.newAttr ? activeEditor : activeEditor.querySelector(".editable");
editable.focus();
}
this.markup.emit("refocusedonedit");
};
// Start listening for mutations until we find an attributes change
// that modifies this attribute.
this.markup._inspector.once("markupmutation", onMutations);
},
/**
* Called when the tag name editor has is done editing.
*/
onTagEdit: function(newTagName, isCommit) {
if (!isCommit || newTagName.toLowerCase() === this.node.tagName.toLowerCase() ||
!("editTagName" in this.markup.walker)) {
return;
}
// Changing the tagName removes the node. Make sure the replacing node gets
// selected afterwards.
this.markup.reselectOnRemoved(this.node, "edittagname");
this.markup.walker.editTagName(this.node, newTagName).then(null, () => {
// Failed to edit the tag name, cancel the reselection.
this.markup.cancelReselectOnRemoved();
});
},
destroy: function() {
for (let key in this.animationTimers) {
clearTimeout(this.animationTimers[key]);
}
this.animationTimers = null;
}
};
function nodeDocument(node) {
return node.ownerDocument ||
(node.nodeType == Ci.nsIDOMNode.DOCUMENT_NODE ? node : null);
}
function truncateString(str, maxLength) {
if (!str || str.length <= maxLength) {
return str;
}
return str.substring(0, Math.ceil(maxLength / 2)) +
"…" +
str.substring(str.length - Math.floor(maxLength / 2));
}
/**
* Parse attribute names and values from a string.
*
* @param {String} attr
* The input string for which names/values are to be parsed.
* @param {HTMLDocument} doc
* A document that can be used to test valid attributes.
* @return {Array}
* An array of attribute names and their values.
*/
function parseAttributeValues(attr, doc) {
attr = attr.trim();
// Handle bad user inputs by appending a " or ' if it fails to parse without
// them. Also note that a SVG tag is used to make sure the HTML parser
// preserves mixed-case attributes
let el = DOMParser.parseFromString("<svg " + attr + "></svg>", "text/html").body.childNodes[0] ||
DOMParser.parseFromString("<svg " + attr + "\"></svg>", "text/html").body.childNodes[0] ||
DOMParser.parseFromString("<svg " + attr + "'></svg>", "text/html").body.childNodes[0];
let div = doc.createElement("div");
let attributes = [];
for (let {name, value} of el.attributes) {
// Try to set on an element in the document, throws exception on bad input.
// Prevents InvalidCharacterError - "String contains an invalid character".
try {
div.setAttribute(name, value);
attributes.push({ name, value });
}
catch(e) { }
}
// Attributes return from DOMParser in reverse order from how they are entered.
return attributes.reverse();
}
/**
* Apply a 'flashed' background and foreground color to elements. Intended
* to be used with flashElementOff as a way of drawing attention to an element.
*
* @param {Node} backgroundElt
* The element to set the highlighted background color on.
* @param {Node} foregroundElt
* The element to set the matching foreground color on.
* Optional. This will equal backgroundElt if not set.
*/
function flashElementOn(backgroundElt, foregroundElt=backgroundElt) {
if (!backgroundElt || !foregroundElt) {
return;
}
// Make sure the animation class is not here
backgroundElt.classList.remove("flash-out");
// Change the background
backgroundElt.classList.add("theme-bg-contrast");
foregroundElt.classList.add("theme-fg-contrast");
[].forEach.call(
foregroundElt.querySelectorAll("[class*=theme-fg-color]"),
span => span.classList.add("theme-fg-contrast")
);
}
/**
* Remove a 'flashed' background and foreground color to elements.
* See flashElementOn.
*
* @param {Node} backgroundElt
* The element to reomve the highlighted background color on.
* @param {Node} foregroundElt
* The element to remove the matching foreground color on.
* Optional. This will equal backgroundElt if not set.
*/
function flashElementOff(backgroundElt, foregroundElt=backgroundElt) {
if (!backgroundElt || !foregroundElt) {
return;
}
// Add the animation class to smoothly remove the background
backgroundElt.classList.add("flash-out");
// Remove the background
backgroundElt.classList.remove("theme-bg-contrast");
foregroundElt.classList.remove("theme-fg-contrast");
[].forEach.call(
foregroundElt.querySelectorAll("[class*=theme-fg-color]"),
span => span.classList.remove("theme-fg-contrast")
);
}
/**
* 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);
}
loader.lazyGetter(MarkupView.prototype, "strings", () => Services.strings.createBundle(
"chrome://devtools/locale/inspector.properties"
));
XPCOMUtils.defineLazyGetter(this, "clipboardHelper", function() {
return Cc["@mozilla.org/widget/clipboardhelper;1"].
getService(Ci.nsIClipboardHelper);
});