browser/devtools/markupview/markup-view.js

changeset 0
6474c204b198
equal deleted inserted replaced
-1:000000000000 0:cc0396f6f692
1 /* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
2 /* vim: set ft=javascript ts=2 et sw=2 tw=80: */
3 /* This Source Code Form is subject to the terms of the Mozilla Public
4 * License, v. 2.0. If a copy of the MPL was not distributed with this
5 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6
7 const {Cc, Cu, Ci} = require("chrome");
8
9 // Page size for pageup/pagedown
10 const PAGE_SIZE = 10;
11 const PREVIEW_AREA = 700;
12 const DEFAULT_MAX_CHILDREN = 100;
13 const COLLAPSE_ATTRIBUTE_LENGTH = 120;
14 const COLLAPSE_DATA_URL_REGEX = /^data.+base64/;
15 const COLLAPSE_DATA_URL_LENGTH = 60;
16 const CONTAINER_FLASHING_DURATION = 500;
17 const NEW_SELECTION_HIGHLIGHTER_TIMER = 1000;
18
19 const {UndoStack} = require("devtools/shared/undo");
20 const {editableField, InplaceEditor} = require("devtools/shared/inplace-editor");
21 const {gDevTools} = Cu.import("resource:///modules/devtools/gDevTools.jsm", {});
22 const {HTMLEditor} = require("devtools/markupview/html-editor");
23 const promise = require("devtools/toolkit/deprecated-sync-thenables");
24 const {Tooltip} = require("devtools/shared/widgets/Tooltip");
25 const EventEmitter = require("devtools/toolkit/event-emitter");
26
27 Cu.import("resource://gre/modules/devtools/LayoutHelpers.jsm");
28 Cu.import("resource://gre/modules/devtools/Templater.jsm");
29 Cu.import("resource://gre/modules/Services.jsm");
30 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
31
32 loader.lazyGetter(this, "DOMParser", function() {
33 return Cc["@mozilla.org/xmlextras/domparser;1"].createInstance(Ci.nsIDOMParser);
34 });
35 loader.lazyGetter(this, "AutocompletePopup", () => {
36 return require("devtools/shared/autocomplete-popup").AutocompletePopup
37 });
38
39 /**
40 * Vocabulary for the purposes of this file:
41 *
42 * MarkupContainer - the structure that holds an editor and its
43 * immediate children in the markup panel.
44 * Node - A content node.
45 * object.elt - A UI element in the markup panel.
46 */
47
48 /**
49 * The markup tree. Manages the mapping of nodes to MarkupContainers,
50 * updating based on mutations, and the undo/redo bindings.
51 *
52 * @param Inspector aInspector
53 * The inspector we're watching.
54 * @param iframe aFrame
55 * An iframe in which the caller has kindly loaded markup-view.xhtml.
56 */
57 function MarkupView(aInspector, aFrame, aControllerWindow) {
58 this._inspector = aInspector;
59 this.walker = this._inspector.walker;
60 this._frame = aFrame;
61 this.doc = this._frame.contentDocument;
62 this._elt = this.doc.querySelector("#root");
63 this.htmlEditor = new HTMLEditor(this.doc);
64
65 this.layoutHelpers = new LayoutHelpers(this.doc.defaultView);
66
67 try {
68 this.maxChildren = Services.prefs.getIntPref("devtools.markup.pagesize");
69 } catch(ex) {
70 this.maxChildren = DEFAULT_MAX_CHILDREN;
71 }
72
73 // Creating the popup to be used to show CSS suggestions.
74 let options = {
75 autoSelect: true,
76 theme: "auto"
77 };
78 this.popup = new AutocompletePopup(this.doc.defaultView.parent.document, options);
79
80 this.undo = new UndoStack();
81 this.undo.installController(aControllerWindow);
82
83 this._containers = new Map();
84
85 this._boundMutationObserver = this._mutationObserver.bind(this);
86 this.walker.on("mutations", this._boundMutationObserver);
87
88 this._boundOnNewSelection = this._onNewSelection.bind(this);
89 this._inspector.selection.on("new-node-front", this._boundOnNewSelection);
90 this._onNewSelection();
91
92 this._boundKeyDown = this._onKeyDown.bind(this);
93 this._frame.contentWindow.addEventListener("keydown", this._boundKeyDown, false);
94
95 this._boundFocus = this._onFocus.bind(this);
96 this._frame.addEventListener("focus", this._boundFocus, false);
97
98 this._initPreview();
99 this._initTooltips();
100 this._initHighlighter();
101
102 EventEmitter.decorate(this);
103 }
104
105 exports.MarkupView = MarkupView;
106
107 MarkupView.prototype = {
108 _selectedContainer: null,
109
110 _initTooltips: function() {
111 this.tooltip = new Tooltip(this._inspector.panelDoc);
112 this.tooltip.startTogglingOnHover(this._elt,
113 this._isImagePreviewTarget.bind(this));
114 },
115
116 _initHighlighter: function() {
117 // Show the box model on markup-view mousemove
118 this._onMouseMove = this._onMouseMove.bind(this);
119 this._elt.addEventListener("mousemove", this._onMouseMove, false);
120 this._onMouseLeave = this._onMouseLeave.bind(this);
121 this._elt.addEventListener("mouseleave", this._onMouseLeave, false);
122
123 // Show markup-containers as hovered on toolbox "picker-node-hovered" event
124 // which happens when the "pick" button is pressed
125 this._onToolboxPickerHover = (event, nodeFront) => {
126 this.showNode(nodeFront, true).then(() => {
127 this._showContainerAsHovered(nodeFront);
128 });
129 }
130 this._inspector.toolbox.on("picker-node-hovered", this._onToolboxPickerHover);
131 },
132
133 _onMouseMove: function(event) {
134 let target = event.target;
135
136 // Search target for a markupContainer reference, if not found, walk up
137 while (!target.container) {
138 if (target.tagName.toLowerCase() === "body") {
139 return;
140 }
141 target = target.parentNode;
142 }
143
144 let container = target.container;
145 if (this._hoveredNode !== container.node) {
146 if (container.node.nodeType !== Ci.nsIDOMNode.TEXT_NODE) {
147 this._showBoxModel(container.node);
148 } else {
149 this._hideBoxModel();
150 }
151 }
152 this._showContainerAsHovered(container.node);
153 },
154
155 _hoveredNode: null,
156 _showContainerAsHovered: function(nodeFront) {
157 if (this._hoveredNode !== nodeFront) {
158 if (this._hoveredNode) {
159 this._containers.get(this._hoveredNode).hovered = false;
160 }
161 this._containers.get(nodeFront).hovered = true;
162
163 this._hoveredNode = nodeFront;
164 }
165 },
166
167 _onMouseLeave: function() {
168 this._hideBoxModel(true);
169 if (this._hoveredNode) {
170 this._containers.get(this._hoveredNode).hovered = false;
171 }
172 this._hoveredNode = null;
173 },
174
175 _showBoxModel: function(nodeFront, options={}) {
176 this._inspector.toolbox.highlighterUtils.highlightNodeFront(nodeFront, options);
177 },
178
179 _hideBoxModel: function(forceHide) {
180 return this._inspector.toolbox.highlighterUtils.unhighlight(forceHide);
181 },
182
183 _briefBoxModelTimer: null,
184 _brieflyShowBoxModel: function(nodeFront, options) {
185 let win = this._frame.contentWindow;
186
187 if (this._briefBoxModelTimer) {
188 win.clearTimeout(this._briefBoxModelTimer);
189 this._briefBoxModelTimer = null;
190 }
191
192 this._showBoxModel(nodeFront, options);
193
194 this._briefBoxModelTimer = this._frame.contentWindow.setTimeout(() => {
195 this._hideBoxModel();
196 }, NEW_SELECTION_HIGHLIGHTER_TIMER);
197 },
198
199 template: function(aName, aDest, aOptions={stack: "markup-view.xhtml"}) {
200 let node = this.doc.getElementById("template-" + aName).cloneNode(true);
201 node.removeAttribute("id");
202 template(node, aDest, aOptions);
203 return node;
204 },
205
206 /**
207 * Get the MarkupContainer object for a given node, or undefined if
208 * none exists.
209 */
210 getContainer: function(aNode) {
211 return this._containers.get(aNode);
212 },
213
214 update: function() {
215 let updateChildren = function(node) {
216 this.getContainer(node).update();
217 for (let child of node.treeChildren()) {
218 updateChildren(child);
219 }
220 }.bind(this);
221
222 // Start with the documentElement
223 let documentElement;
224 for (let node of this._rootNode.treeChildren()) {
225 if (node.isDocumentElement === true) {
226 documentElement = node;
227 break;
228 }
229 }
230
231 // Recursively update each node starting with documentElement.
232 updateChildren(documentElement);
233 },
234
235 /**
236 * Executed when the mouse hovers over a target in the markup-view and is used
237 * to decide whether this target should be used to display an image preview
238 * tooltip.
239 * Delegates the actual decision to the corresponding MarkupContainer instance
240 * if one is found.
241 * @return the promise returned by MarkupContainer._isImagePreviewTarget
242 */
243 _isImagePreviewTarget: function(target) {
244 // From the target passed here, let's find the parent MarkupContainer
245 // and ask it if the tooltip should be shown
246 let parent = target, container;
247 while (parent !== this.doc.body) {
248 if (parent.container) {
249 container = parent.container;
250 break;
251 }
252 parent = parent.parentNode;
253 }
254
255 if (container) {
256 // With the newly found container, delegate the tooltip content creation
257 // and decision to show or not the tooltip
258 return container._isImagePreviewTarget(target, this.tooltip);
259 }
260 },
261
262 /**
263 * Given the known reason, should the current selection be briefly highlighted
264 * In a few cases, we don't want to highlight the node:
265 * - If the reason is null (used to reset the selection),
266 * - if it's "inspector-open" (when the inspector opens up, let's not highlight
267 * the default node)
268 * - if it's "navigateaway" (since the page is being navigated away from)
269 * - if it's "test" (this is a special case for mochitest. In tests, we often
270 * need to select elements but don't necessarily want the highlighter to come
271 * and go after a delay as this might break test scenarios)
272 * We also do not want to start a brief highlight timeout if the node is already
273 * being hovered over, since in that case it will already be highlighted.
274 */
275 _shouldNewSelectionBeHighlighted: function() {
276 let reason = this._inspector.selection.reason;
277 let unwantedReasons = ["inspector-open", "navigateaway", "test"];
278 let isHighlitNode = this._hoveredNode === this._inspector.selection.nodeFront;
279 return !isHighlitNode && reason && unwantedReasons.indexOf(reason) === -1;
280 },
281
282 /**
283 * Highlight the inspector selected node.
284 */
285 _onNewSelection: function() {
286 let selection = this._inspector.selection;
287
288 this.htmlEditor.hide();
289 let done = this._inspector.updating("markup-view");
290 if (selection.isNode()) {
291 if (this._shouldNewSelectionBeHighlighted()) {
292 this._brieflyShowBoxModel(selection.nodeFront, {});
293 }
294
295 this.showNode(selection.nodeFront, true).then(() => {
296 if (selection.reason !== "treepanel") {
297 this.markNodeAsSelected(selection.nodeFront);
298 }
299 done();
300 }, (e) => {
301 console.error(e);
302 done();
303 });
304 } else {
305 this.unmarkSelectedNode();
306 done();
307 }
308 },
309
310 /**
311 * Create a TreeWalker to find the next/previous
312 * node for selection.
313 */
314 _selectionWalker: function(aStart) {
315 let walker = this.doc.createTreeWalker(
316 aStart || this._elt,
317 Ci.nsIDOMNodeFilter.SHOW_ELEMENT,
318 function(aElement) {
319 if (aElement.container &&
320 aElement.container.elt === aElement &&
321 aElement.container.visible) {
322 return Ci.nsIDOMNodeFilter.FILTER_ACCEPT;
323 }
324 return Ci.nsIDOMNodeFilter.FILTER_SKIP;
325 }
326 );
327 walker.currentNode = this._selectedContainer.elt;
328 return walker;
329 },
330
331 /**
332 * Key handling.
333 */
334 _onKeyDown: function(aEvent) {
335 let handled = true;
336
337 // Ignore keystrokes that originated in editors.
338 if (aEvent.target.tagName.toLowerCase() === "input" ||
339 aEvent.target.tagName.toLowerCase() === "textarea") {
340 return;
341 }
342
343 switch(aEvent.keyCode) {
344 case Ci.nsIDOMKeyEvent.DOM_VK_H:
345 let node = this._selectedContainer.node;
346 if (node.hidden) {
347 this.walker.unhideNode(node).then(() => this.nodeChanged(node));
348 } else {
349 this.walker.hideNode(node).then(() => this.nodeChanged(node));
350 }
351 break;
352 case Ci.nsIDOMKeyEvent.DOM_VK_DELETE:
353 case Ci.nsIDOMKeyEvent.DOM_VK_BACK_SPACE:
354 this.deleteNode(this._selectedContainer.node);
355 break;
356 case Ci.nsIDOMKeyEvent.DOM_VK_HOME:
357 let rootContainer = this._containers.get(this._rootNode);
358 this.navigate(rootContainer.children.firstChild.container);
359 break;
360 case Ci.nsIDOMKeyEvent.DOM_VK_LEFT:
361 if (this._selectedContainer.expanded) {
362 this.collapseNode(this._selectedContainer.node);
363 } else {
364 let parent = this._selectionWalker().parentNode();
365 if (parent) {
366 this.navigate(parent.container);
367 }
368 }
369 break;
370 case Ci.nsIDOMKeyEvent.DOM_VK_RIGHT:
371 if (!this._selectedContainer.expanded &&
372 this._selectedContainer.hasChildren) {
373 this._expandContainer(this._selectedContainer);
374 } else {
375 let next = this._selectionWalker().nextNode();
376 if (next) {
377 this.navigate(next.container);
378 }
379 }
380 break;
381 case Ci.nsIDOMKeyEvent.DOM_VK_UP:
382 let prev = this._selectionWalker().previousNode();
383 if (prev) {
384 this.navigate(prev.container);
385 }
386 break;
387 case Ci.nsIDOMKeyEvent.DOM_VK_DOWN:
388 let next = this._selectionWalker().nextNode();
389 if (next) {
390 this.navigate(next.container);
391 }
392 break;
393 case Ci.nsIDOMKeyEvent.DOM_VK_PAGE_UP: {
394 let walker = this._selectionWalker();
395 let selection = this._selectedContainer;
396 for (let i = 0; i < PAGE_SIZE; i++) {
397 let prev = walker.previousNode();
398 if (!prev) {
399 break;
400 }
401 selection = prev.container;
402 }
403 this.navigate(selection);
404 break;
405 }
406 case Ci.nsIDOMKeyEvent.DOM_VK_PAGE_DOWN: {
407 let walker = this._selectionWalker();
408 let selection = this._selectedContainer;
409 for (let i = 0; i < PAGE_SIZE; i++) {
410 let next = walker.nextNode();
411 if (!next) {
412 break;
413 }
414 selection = next.container;
415 }
416 this.navigate(selection);
417 break;
418 }
419 case Ci.nsIDOMKeyEvent.DOM_VK_F2: {
420 this.beginEditingOuterHTML(this._selectedContainer.node);
421 break;
422 }
423 default:
424 handled = false;
425 }
426 if (handled) {
427 aEvent.stopPropagation();
428 aEvent.preventDefault();
429 }
430 },
431
432 /**
433 * Delete a node from the DOM.
434 * This is an undoable action.
435 */
436 deleteNode: function(aNode) {
437 if (aNode.isDocumentElement ||
438 aNode.nodeType == Ci.nsIDOMNode.DOCUMENT_TYPE_NODE) {
439 return;
440 }
441
442 let container = this._containers.get(aNode);
443
444 // Retain the node so we can undo this...
445 this.walker.retainNode(aNode).then(() => {
446 let parent = aNode.parentNode();
447 let sibling = null;
448 this.undo.do(() => {
449 if (container.selected) {
450 this.navigate(this._containers.get(parent));
451 }
452 this.walker.removeNode(aNode).then(nextSibling => {
453 sibling = nextSibling;
454 });
455 }, () => {
456 this.walker.insertBefore(aNode, parent, sibling);
457 });
458 }).then(null, console.error);
459 },
460
461 /**
462 * If an editable item is focused, select its container.
463 */
464 _onFocus: function(aEvent) {
465 let parent = aEvent.target;
466 while (!parent.container) {
467 parent = parent.parentNode;
468 }
469 if (parent) {
470 this.navigate(parent.container, true);
471 }
472 },
473
474 /**
475 * Handle a user-requested navigation to a given MarkupContainer,
476 * updating the inspector's currently-selected node.
477 *
478 * @param MarkupContainer aContainer
479 * The container we're navigating to.
480 * @param aIgnoreFocus aIgnoreFocus
481 * If falsy, keyboard focus will be moved to the container too.
482 */
483 navigate: function(aContainer, aIgnoreFocus) {
484 if (!aContainer) {
485 return;
486 }
487
488 let node = aContainer.node;
489 this.markNodeAsSelected(node, "treepanel");
490
491 if (!aIgnoreFocus) {
492 aContainer.focus();
493 }
494 },
495
496 /**
497 * Make sure a node is included in the markup tool.
498 *
499 * @param DOMNode aNode
500 * The node in the content document.
501 * @param boolean aFlashNode
502 * Whether the newly imported node should be flashed
503 * @returns MarkupContainer The MarkupContainer object for this element.
504 */
505 importNode: function(aNode, aFlashNode) {
506 if (!aNode) {
507 return null;
508 }
509
510 if (this._containers.has(aNode)) {
511 return this._containers.get(aNode);
512 }
513
514 if (aNode === this.walker.rootNode) {
515 var container = new RootContainer(this, aNode);
516 this._elt.appendChild(container.elt);
517 this._rootNode = aNode;
518 } else {
519 var container = new MarkupContainer(this, aNode, this._inspector);
520 if (aFlashNode) {
521 container.flashMutation();
522 }
523 }
524
525 this._containers.set(aNode, container);
526 container.childrenDirty = true;
527
528 this._updateChildren(container);
529
530 return container;
531 },
532
533 /**
534 * Mutation observer used for included nodes.
535 */
536 _mutationObserver: function(aMutations) {
537 let requiresLayoutChange = false;
538 let reselectParent;
539 let reselectChildIndex;
540
541 for (let mutation of aMutations) {
542 let type = mutation.type;
543 let target = mutation.target;
544
545 if (mutation.type === "documentUnload") {
546 // Treat this as a childList change of the child (maybe the protocol
547 // should do this).
548 type = "childList";
549 target = mutation.targetParent;
550 if (!target) {
551 continue;
552 }
553 }
554
555 let container = this._containers.get(target);
556 if (!container) {
557 // Container might not exist if this came from a load event for a node
558 // we're not viewing.
559 continue;
560 }
561 if (type === "attributes" || type === "characterData") {
562 container.update();
563
564 // Auto refresh style properties on selected node when they change.
565 if (type === "attributes" && container.selected) {
566 requiresLayoutChange = true;
567 }
568 } else if (type === "childList") {
569 let isFromOuterHTML = mutation.removed.some((n) => {
570 return n === this._outerHTMLNode;
571 });
572
573 // Keep track of which node should be reselected after mutations.
574 if (isFromOuterHTML) {
575 reselectParent = target;
576 reselectChildIndex = this._outerHTMLChildIndex;
577
578 delete this._outerHTMLNode;
579 delete this._outerHTMLChildIndex;
580 }
581
582 container.childrenDirty = true;
583 // Update the children to take care of changes in the markup view DOM.
584 this._updateChildren(container, {flash: !isFromOuterHTML});
585 }
586 }
587
588 if (requiresLayoutChange) {
589 this._inspector.immediateLayoutChange();
590 }
591 this._waitForChildren().then((nodes) => {
592 this._flashMutatedNodes(aMutations);
593 this._inspector.emit("markupmutation", aMutations);
594
595 // Since the htmlEditor is absolutely positioned, a mutation may change
596 // the location in which it should be shown.
597 this.htmlEditor.refresh();
598
599 // If a node has had its outerHTML set, the parent node will be selected.
600 // Reselect the original node immediately.
601 if (this._inspector.selection.nodeFront === reselectParent) {
602 this.walker.children(reselectParent).then((o) => {
603 let node = o.nodes[reselectChildIndex];
604 let container = this._containers.get(node);
605 if (node && container) {
606 this.markNodeAsSelected(node, "outerhtml");
607 if (container.hasChildren) {
608 this.expandNode(node);
609 }
610 }
611 });
612
613 }
614 });
615 },
616
617 /**
618 * Given a list of mutations returned by the mutation observer, flash the
619 * corresponding containers to attract attention.
620 */
621 _flashMutatedNodes: function(aMutations) {
622 let addedOrEditedContainers = new Set();
623 let removedContainers = new Set();
624
625 for (let {type, target, added, removed} of aMutations) {
626 let container = this._containers.get(target);
627
628 if (container) {
629 if (type === "attributes" || type === "characterData") {
630 addedOrEditedContainers.add(container);
631 } else if (type === "childList") {
632 // If there has been removals, flash the parent
633 if (removed.length) {
634 removedContainers.add(container);
635 }
636
637 // If there has been additions, flash the nodes
638 added.forEach(added => {
639 let addedContainer = this._containers.get(added);
640 addedOrEditedContainers.add(addedContainer);
641
642 // The node may be added as a result of an append, in which case it
643 // it will have been removed from another container first, but in
644 // these cases we don't want to flash both the removal and the
645 // addition
646 removedContainers.delete(container);
647 });
648 }
649 }
650 }
651
652 for (let container of removedContainers) {
653 container.flashMutation();
654 }
655 for (let container of addedOrEditedContainers) {
656 container.flashMutation();
657 }
658 },
659
660 /**
661 * Make sure the given node's parents are expanded and the
662 * node is scrolled on to screen.
663 */
664 showNode: function(aNode, centered) {
665 let parent = aNode;
666
667 this.importNode(aNode);
668
669 while ((parent = parent.parentNode())) {
670 this.importNode(parent);
671 this.expandNode(parent);
672 }
673
674 return this._waitForChildren().then(() => {
675 return this._ensureVisible(aNode);
676 }).then(() => {
677 // Why is this not working?
678 this.layoutHelpers.scrollIntoViewIfNeeded(this._containers.get(aNode).editor.elt, centered);
679 });
680 },
681
682 /**
683 * Expand the container's children.
684 */
685 _expandContainer: function(aContainer) {
686 return this._updateChildren(aContainer, {expand: true}).then(() => {
687 aContainer.expanded = true;
688 });
689 },
690
691 /**
692 * Expand the node's children.
693 */
694 expandNode: function(aNode) {
695 let container = this._containers.get(aNode);
696 this._expandContainer(container);
697 },
698
699 /**
700 * Expand the entire tree beneath a container.
701 *
702 * @param aContainer The container to expand.
703 */
704 _expandAll: function(aContainer) {
705 return this._expandContainer(aContainer).then(() => {
706 let child = aContainer.children.firstChild;
707 let promises = [];
708 while (child) {
709 promises.push(this._expandAll(child.container));
710 child = child.nextSibling;
711 }
712 return promise.all(promises);
713 }).then(null, console.error);
714 },
715
716 /**
717 * Expand the entire tree beneath a node.
718 *
719 * @param aContainer The node to expand, or null
720 * to start from the top.
721 */
722 expandAll: function(aNode) {
723 aNode = aNode || this._rootNode;
724 return this._expandAll(this._containers.get(aNode));
725 },
726
727 /**
728 * Collapse the node's children.
729 */
730 collapseNode: function(aNode) {
731 let container = this._containers.get(aNode);
732 container.expanded = false;
733 },
734
735 /**
736 * Retrieve the outerHTML for a remote node.
737 * @param aNode The NodeFront to get the outerHTML for.
738 * @returns A promise that will be resolved with the outerHTML.
739 */
740 getNodeOuterHTML: function(aNode) {
741 let def = promise.defer();
742 this.walker.outerHTML(aNode).then(longstr => {
743 longstr.string().then(outerHTML => {
744 longstr.release().then(null, console.error);
745 def.resolve(outerHTML);
746 });
747 });
748 return def.promise;
749 },
750
751 /**
752 * Retrieve the index of a child within its parent's children list.
753 * @param aNode The NodeFront to find the index of.
754 * @returns A promise that will be resolved with the integer index.
755 * If the child cannot be found, returns -1
756 */
757 getNodeChildIndex: function(aNode) {
758 let def = promise.defer();
759 let parentNode = aNode.parentNode();
760
761 // Node may have been removed from the DOM, instead of throwing an error,
762 // return -1 indicating that it isn't inside of its parent children list.
763 if (!parentNode) {
764 def.resolve(-1);
765 } else {
766 this.walker.children(parentNode).then(children => {
767 def.resolve(children.nodes.indexOf(aNode));
768 });
769 }
770
771 return def.promise;
772 },
773
774 /**
775 * Retrieve the index of a child within its parent's children collection.
776 * @param aNode The NodeFront to find the index of.
777 * @param newValue The new outerHTML to set on the node.
778 * @param oldValue The old outerHTML that will be reverted to find the index of.
779 * @returns A promise that will be resolved with the integer index.
780 * If the child cannot be found, returns -1
781 */
782 updateNodeOuterHTML: function(aNode, newValue, oldValue) {
783 let container = this._containers.get(aNode);
784 if (!container) {
785 return;
786 }
787
788 this.getNodeChildIndex(aNode).then((i) => {
789 this._outerHTMLChildIndex = i;
790 this._outerHTMLNode = aNode;
791
792 container.undo.do(() => {
793 this.walker.setOuterHTML(aNode, newValue);
794 }, () => {
795 this.walker.setOuterHTML(aNode, oldValue);
796 });
797 });
798 },
799
800 /**
801 * Open an editor in the UI to allow editing of a node's outerHTML.
802 * @param aNode The NodeFront to edit.
803 */
804 beginEditingOuterHTML: function(aNode) {
805 this.getNodeOuterHTML(aNode).then((oldValue)=> {
806 let container = this._containers.get(aNode);
807 if (!container) {
808 return;
809 }
810 this.htmlEditor.show(container.tagLine, oldValue);
811 this.htmlEditor.once("popuphidden", (e, aCommit, aValue) => {
812 // Need to focus the <html> element instead of the frame / window
813 // in order to give keyboard focus back to doc (from editor).
814 this._frame.contentDocument.documentElement.focus();
815
816 if (aCommit) {
817 this.updateNodeOuterHTML(aNode, aValue, oldValue);
818 }
819 });
820 });
821 },
822
823 /**
824 * Mark the given node expanded.
825 * @param {NodeFront} aNode The NodeFront to mark as expanded.
826 * @param {Boolean} aExpanded Whether the expand or collapse.
827 * @param {Boolean} aExpandDescendants Whether to expand all descendants too
828 */
829 setNodeExpanded: function(aNode, aExpanded, aExpandDescendants) {
830 if (aExpanded) {
831 if (aExpandDescendants) {
832 this.expandAll(aNode);
833 } else {
834 this.expandNode(aNode);
835 }
836 } else {
837 this.collapseNode(aNode);
838 }
839 },
840
841 /**
842 * Mark the given node selected, and update the inspector.selection
843 * object's NodeFront to keep consistent state between UI and selection.
844 * @param aNode The NodeFront to mark as selected.
845 */
846 markNodeAsSelected: function(aNode, reason) {
847 let container = this._containers.get(aNode);
848 if (this._selectedContainer === container) {
849 return false;
850 }
851 if (this._selectedContainer) {
852 this._selectedContainer.selected = false;
853 }
854 this._selectedContainer = container;
855 if (aNode) {
856 this._selectedContainer.selected = true;
857 }
858
859 this._inspector.selection.setNodeFront(aNode, reason || "nodeselected");
860 return true;
861 },
862
863 /**
864 * Make sure that every ancestor of the selection are updated
865 * and included in the list of visible children.
866 */
867 _ensureVisible: function(node) {
868 while (node) {
869 let container = this._containers.get(node);
870 let parent = node.parentNode();
871 if (!container.elt.parentNode) {
872 let parentContainer = this._containers.get(parent);
873 if (parentContainer) {
874 parentContainer.childrenDirty = true;
875 this._updateChildren(parentContainer, {expand: node});
876 }
877 }
878
879 node = parent;
880 }
881 return this._waitForChildren();
882 },
883
884 /**
885 * Unmark selected node (no node selected).
886 */
887 unmarkSelectedNode: function() {
888 if (this._selectedContainer) {
889 this._selectedContainer.selected = false;
890 this._selectedContainer = null;
891 }
892 },
893
894 /**
895 * Called when the markup panel initiates a change on a node.
896 */
897 nodeChanged: function(aNode) {
898 if (aNode === this._inspector.selection.nodeFront) {
899 this._inspector.change("markupview");
900 }
901 },
902
903 /**
904 * Check if the current selection is a descendent of the container.
905 * if so, make sure it's among the visible set for the container,
906 * and set the dirty flag if needed.
907 * @returns The node that should be made visible, if any.
908 */
909 _checkSelectionVisible: function(aContainer) {
910 let centered = null;
911 let node = this._inspector.selection.nodeFront;
912 while (node) {
913 if (node.parentNode() === aContainer.node) {
914 centered = node;
915 break;
916 }
917 node = node.parentNode();
918 }
919
920 return centered;
921 },
922
923 /**
924 * Make sure all children of the given container's node are
925 * imported and attached to the container in the right order.
926 *
927 * Children need to be updated only in the following circumstances:
928 * a) We just imported this node and have never seen its children.
929 * container.childrenDirty will be set by importNode in this case.
930 * b) We received a childList mutation on the node.
931 * container.childrenDirty will be set in that case too.
932 * c) We have changed the selection, and the path to that selection
933 * wasn't loaded in a previous children request (because we only
934 * grab a subset).
935 * container.childrenDirty should be set in that case too!
936 *
937 * @param MarkupContainer aContainer
938 * The markup container whose children need updating
939 * @param Object options
940 * Options are {expand:boolean,flash:boolean}
941 * @return a promise that will be resolved when the children are ready
942 * (which may be immediately).
943 */
944 _updateChildren: function(aContainer, options) {
945 let expand = options && options.expand;
946 let flash = options && options.flash;
947
948 aContainer.hasChildren = aContainer.node.hasChildren;
949
950 if (!this._queuedChildUpdates) {
951 this._queuedChildUpdates = new Map();
952 }
953
954 if (this._queuedChildUpdates.has(aContainer)) {
955 return this._queuedChildUpdates.get(aContainer);
956 }
957
958 if (!aContainer.childrenDirty) {
959 return promise.resolve(aContainer);
960 }
961
962 if (!aContainer.hasChildren) {
963 while (aContainer.children.firstChild) {
964 aContainer.children.removeChild(aContainer.children.firstChild);
965 }
966 aContainer.childrenDirty = false;
967 return promise.resolve(aContainer);
968 }
969
970 // If we're not expanded (or asked to update anyway), we're done for
971 // now. Note that this will leave the childrenDirty flag set, so when
972 // expanded we'll refresh the child list.
973 if (!(aContainer.expanded || expand)) {
974 return promise.resolve(aContainer);
975 }
976
977 // We're going to issue a children request, make sure it includes the
978 // centered node.
979 let centered = this._checkSelectionVisible(aContainer);
980
981 // Children aren't updated yet, but clear the childrenDirty flag anyway.
982 // If the dirty flag is re-set while we're fetching we'll need to fetch
983 // again.
984 aContainer.childrenDirty = false;
985 let updatePromise = this._getVisibleChildren(aContainer, centered).then(children => {
986 if (!this._containers) {
987 return promise.reject("markup view destroyed");
988 }
989 this._queuedChildUpdates.delete(aContainer);
990
991 // If children are dirty, we got a change notification for this node
992 // while the request was in progress, we need to do it again.
993 if (aContainer.childrenDirty) {
994 return this._updateChildren(aContainer, {expand: centered});
995 }
996
997 let fragment = this.doc.createDocumentFragment();
998
999 for (let child of children.nodes) {
1000 let container = this.importNode(child, flash);
1001 fragment.appendChild(container.elt);
1002 }
1003
1004 while (aContainer.children.firstChild) {
1005 aContainer.children.removeChild(aContainer.children.firstChild);
1006 }
1007
1008 if (!(children.hasFirst && children.hasLast)) {
1009 let data = {
1010 showing: this.strings.GetStringFromName("markupView.more.showing"),
1011 showAll: this.strings.formatStringFromName(
1012 "markupView.more.showAll",
1013 [aContainer.node.numChildren.toString()], 1),
1014 allButtonClick: () => {
1015 aContainer.maxChildren = -1;
1016 aContainer.childrenDirty = true;
1017 this._updateChildren(aContainer);
1018 }
1019 };
1020
1021 if (!children.hasFirst) {
1022 let span = this.template("more-nodes", data);
1023 fragment.insertBefore(span, fragment.firstChild);
1024 }
1025 if (!children.hasLast) {
1026 let span = this.template("more-nodes", data);
1027 fragment.appendChild(span);
1028 }
1029 }
1030
1031 aContainer.children.appendChild(fragment);
1032 return aContainer;
1033 }).then(null, console.error);
1034 this._queuedChildUpdates.set(aContainer, updatePromise);
1035 return updatePromise;
1036 },
1037
1038 _waitForChildren: function() {
1039 if (!this._queuedChildUpdates) {
1040 return promise.resolve(undefined);
1041 }
1042 return promise.all([updatePromise for (updatePromise of this._queuedChildUpdates.values())]);
1043 },
1044
1045 /**
1046 * Return a list of the children to display for this container.
1047 */
1048 _getVisibleChildren: function(aContainer, aCentered) {
1049 let maxChildren = aContainer.maxChildren || this.maxChildren;
1050 if (maxChildren == -1) {
1051 maxChildren = undefined;
1052 }
1053
1054 return this.walker.children(aContainer.node, {
1055 maxNodes: maxChildren,
1056 center: aCentered
1057 });
1058 },
1059
1060 /**
1061 * Tear down the markup panel.
1062 */
1063 destroy: function() {
1064 if (this._destroyer) {
1065 return this._destroyer;
1066 }
1067
1068 // Note that if the toolbox is closed, this will work fine, but will fail
1069 // in case the browser is closed and will trigger a noSuchActor message.
1070 this._destroyer = this._hideBoxModel();
1071
1072 this._hoveredNode = null;
1073 this._inspector.toolbox.off("picker-node-hovered", this._onToolboxPickerHover);
1074
1075 this.htmlEditor.destroy();
1076 this.htmlEditor = null;
1077
1078 this.undo.destroy();
1079 this.undo = null;
1080
1081 this.popup.destroy();
1082 this.popup = null;
1083
1084 this._frame.removeEventListener("focus", this._boundFocus, false);
1085 this._boundFocus = null;
1086
1087 if (this._boundUpdatePreview) {
1088 this._frame.contentWindow.removeEventListener("scroll",
1089 this._boundUpdatePreview, true);
1090 this._boundUpdatePreview = null;
1091 }
1092
1093 if (this._boundResizePreview) {
1094 this._frame.contentWindow.removeEventListener("resize",
1095 this._boundResizePreview, true);
1096 this._frame.contentWindow.removeEventListener("overflow",
1097 this._boundResizePreview, true);
1098 this._frame.contentWindow.removeEventListener("underflow",
1099 this._boundResizePreview, true);
1100 this._boundResizePreview = null;
1101 }
1102
1103 this._frame.contentWindow.removeEventListener("keydown",
1104 this._boundKeyDown, false);
1105 this._boundKeyDown = null;
1106
1107 this._inspector.selection.off("new-node-front", this._boundOnNewSelection);
1108 this._boundOnNewSelection = null;
1109
1110 this.walker.off("mutations", this._boundMutationObserver)
1111 this._boundMutationObserver = null;
1112
1113 this._elt.removeEventListener("mousemove", this._onMouseMove, false);
1114 this._elt.removeEventListener("mouseleave", this._onMouseLeave, false);
1115 this._elt = null;
1116
1117 for (let [key, container] of this._containers) {
1118 container.destroy();
1119 }
1120 this._containers = null;
1121
1122 this.tooltip.destroy();
1123 this.tooltip = null;
1124
1125 return this._destroyer;
1126 },
1127
1128 /**
1129 * Initialize the preview panel.
1130 */
1131 _initPreview: function() {
1132 this._previewEnabled = Services.prefs.getBoolPref("devtools.inspector.markupPreview");
1133 if (!this._previewEnabled) {
1134 return;
1135 }
1136
1137 this._previewBar = this.doc.querySelector("#previewbar");
1138 this._preview = this.doc.querySelector("#preview");
1139 this._viewbox = this.doc.querySelector("#viewbox");
1140
1141 this._previewBar.classList.remove("disabled");
1142
1143 this._previewWidth = this._preview.getBoundingClientRect().width;
1144
1145 this._boundResizePreview = this._resizePreview.bind(this);
1146 this._frame.contentWindow.addEventListener("resize",
1147 this._boundResizePreview, true);
1148 this._frame.contentWindow.addEventListener("overflow",
1149 this._boundResizePreview, true);
1150 this._frame.contentWindow.addEventListener("underflow",
1151 this._boundResizePreview, true);
1152
1153 this._boundUpdatePreview = this._updatePreview.bind(this);
1154 this._frame.contentWindow.addEventListener("scroll",
1155 this._boundUpdatePreview, true);
1156 this._updatePreview();
1157 },
1158
1159 /**
1160 * Move the preview viewbox.
1161 */
1162 _updatePreview: function() {
1163 if (!this._previewEnabled) {
1164 return;
1165 }
1166 let win = this._frame.contentWindow;
1167
1168 if (win.scrollMaxY == 0) {
1169 this._previewBar.classList.add("disabled");
1170 return;
1171 }
1172
1173 this._previewBar.classList.remove("disabled");
1174
1175 let ratio = this._previewWidth / PREVIEW_AREA;
1176 let width = ratio * win.innerWidth;
1177
1178 let height = ratio * (win.scrollMaxY + win.innerHeight);
1179 let scrollTo
1180 if (height >= win.innerHeight) {
1181 scrollTo = -(height - win.innerHeight) * (win.scrollY / win.scrollMaxY);
1182 this._previewBar.setAttribute("style", "height:" + height +
1183 "px;transform:translateY(" + scrollTo + "px)");
1184 } else {
1185 this._previewBar.setAttribute("style", "height:100%");
1186 }
1187
1188 let bgSize = ~~width + "px " + ~~height + "px";
1189 this._preview.setAttribute("style", "background-size:" + bgSize);
1190
1191 let height = ~~(win.innerHeight * ratio) + "px";
1192 let top = ~~(win.scrollY * ratio) + "px";
1193 this._viewbox.setAttribute("style", "height:" + height +
1194 ";transform: translateY(" + top + ")");
1195 },
1196
1197 /**
1198 * Hide the preview while resizing, to avoid slowness.
1199 */
1200 _resizePreview: function() {
1201 if (!this._previewEnabled) {
1202 return;
1203 }
1204 let win = this._frame.contentWindow;
1205 this._previewBar.classList.add("hide");
1206 win.clearTimeout(this._resizePreviewTimeout);
1207
1208 win.setTimeout(function() {
1209 this._updatePreview();
1210 this._previewBar.classList.remove("hide");
1211 }.bind(this), 1000);
1212 }
1213 };
1214
1215
1216 /**
1217 * The main structure for storing a document node in the markup
1218 * tree. Manages creation of the editor for the node and
1219 * a <ul> for placing child elements, and expansion/collapsing
1220 * of the element.
1221 *
1222 * @param MarkupView aMarkupView
1223 * The markup view that owns this container.
1224 * @param DOMNode aNode
1225 * The node to display.
1226 * @param Inspector aInspector
1227 * The inspector tool container the markup-view
1228 */
1229 function MarkupContainer(aMarkupView, aNode, aInspector) {
1230 this.markup = aMarkupView;
1231 this.doc = this.markup.doc;
1232 this.undo = this.markup.undo;
1233 this.node = aNode;
1234 this._inspector = aInspector;
1235
1236 if (aNode.nodeType == Ci.nsIDOMNode.TEXT_NODE) {
1237 this.editor = new TextEditor(this, aNode, "text");
1238 } else if (aNode.nodeType == Ci.nsIDOMNode.COMMENT_NODE) {
1239 this.editor = new TextEditor(this, aNode, "comment");
1240 } else if (aNode.nodeType == Ci.nsIDOMNode.ELEMENT_NODE) {
1241 this.editor = new ElementEditor(this, aNode);
1242 } else if (aNode.nodeType == Ci.nsIDOMNode.DOCUMENT_TYPE_NODE) {
1243 this.editor = new DoctypeEditor(this, aNode);
1244 } else {
1245 this.editor = new GenericEditor(this, aNode);
1246 }
1247
1248 // The template will fill the following properties
1249 this.elt = null;
1250 this.expander = null;
1251 this.tagState = null;
1252 this.tagLine = null;
1253 this.children = null;
1254 this.markup.template("container", this);
1255 this.elt.container = this;
1256 this.children.container = this;
1257
1258 // Expanding/collapsing the node on dblclick of the whole tag-line element
1259 this._onToggle = this._onToggle.bind(this);
1260 this.elt.addEventListener("dblclick", this._onToggle, false);
1261 this.expander.addEventListener("click", this._onToggle, false);
1262
1263 // Appending the editor element and attaching event listeners
1264 this.tagLine.appendChild(this.editor.elt);
1265
1266 this._onMouseDown = this._onMouseDown.bind(this);
1267 this.elt.addEventListener("mousedown", this._onMouseDown, false);
1268
1269 // Prepare the image preview tooltip data if any
1270 this._prepareImagePreview();
1271 }
1272
1273 MarkupContainer.prototype = {
1274 toString: function() {
1275 return "[MarkupContainer for " + this.node + "]";
1276 },
1277
1278 isPreviewable: function() {
1279 if (this.node.tagName) {
1280 let tagName = this.node.tagName.toLowerCase();
1281 let srcAttr = this.editor.getAttributeElement("src");
1282 let isImage = tagName === "img" && srcAttr;
1283 let isCanvas = tagName === "canvas";
1284
1285 return isImage || isCanvas;
1286 } else {
1287 return false;
1288 }
1289 },
1290
1291 /**
1292 * If the node is an image or canvas (@see isPreviewable), then get the
1293 * image data uri from the server so that it can then later be previewed in
1294 * a tooltip if needed.
1295 * Stores a promise in this.tooltipData.data that resolves when the data has
1296 * been retrieved
1297 */
1298 _prepareImagePreview: function() {
1299 if (this.isPreviewable()) {
1300 // Get the image data for later so that when the user actually hovers over
1301 // the element, the tooltip does contain the image
1302 let def = promise.defer();
1303
1304 this.tooltipData = {
1305 target: this.editor.getAttributeElement("src") || this.editor.tag,
1306 data: def.promise
1307 };
1308
1309 let maxDim = Services.prefs.getIntPref("devtools.inspector.imagePreviewTooltipSize");
1310 this.node.getImageData(maxDim).then(data => {
1311 data.data.string().then(str => {
1312 let res = {data: str, size: data.size};
1313 // Resolving the data promise and, to always keep tooltipData.data
1314 // as a promise, create a new one that resolves immediately
1315 def.resolve(res);
1316 this.tooltipData.data = promise.resolve(res);
1317 });
1318 }, () => {
1319 this.tooltipData.data = promise.reject();
1320 });
1321 }
1322 },
1323
1324 /**
1325 * Executed by MarkupView._isImagePreviewTarget which is itself called when the
1326 * mouse hovers over a target in the markup-view.
1327 * Checks if the target is indeed something we want to have an image tooltip
1328 * preview over and, if so, inserts content into the tooltip.
1329 * @return a promise that resolves when the content has been inserted or
1330 * rejects if no preview is required. This promise is then used by Tooltip.js
1331 * to decide if/when to show the tooltip
1332 */
1333 _isImagePreviewTarget: function(target, tooltip) {
1334 if (!this.tooltipData || this.tooltipData.target !== target) {
1335 return promise.reject();
1336 }
1337
1338 return this.tooltipData.data.then(({data, size}) => {
1339 tooltip.setImageContent(data, size);
1340 }, () => {
1341 tooltip.setBrokenImageContent();
1342 });
1343 },
1344
1345 copyImageDataUri: function() {
1346 // We need to send again a request to gettooltipData even if one was sent for
1347 // the tooltip, because we want the full-size image
1348 this.node.getImageData().then(data => {
1349 data.data.string().then(str => {
1350 clipboardHelper.copyString(str, this.markup.doc);
1351 });
1352 });
1353 },
1354
1355 /**
1356 * True if the current node has children. The MarkupView
1357 * will set this attribute for the MarkupContainer.
1358 */
1359 _hasChildren: false,
1360
1361 get hasChildren() {
1362 return this._hasChildren;
1363 },
1364
1365 set hasChildren(aValue) {
1366 this._hasChildren = aValue;
1367 if (aValue) {
1368 this.expander.style.visibility = "visible";
1369 } else {
1370 this.expander.style.visibility = "hidden";
1371 }
1372 },
1373
1374 parentContainer: function() {
1375 return this.elt.parentNode ? this.elt.parentNode.container : null;
1376 },
1377
1378 /**
1379 * True if the node has been visually expanded in the tree.
1380 */
1381 get expanded() {
1382 return !this.elt.classList.contains("collapsed");
1383 },
1384
1385 set expanded(aValue) {
1386 if (aValue && this.elt.classList.contains("collapsed")) {
1387 // Expanding a node means cloning its "inline" closing tag into a new
1388 // tag-line that the user can interact with and showing the children.
1389 if (this.editor instanceof ElementEditor) {
1390 let closingTag = this.elt.querySelector(".close");
1391 if (closingTag) {
1392 if (!this.closeTagLine) {
1393 let line = this.markup.doc.createElement("div");
1394 line.classList.add("tag-line");
1395
1396 let tagState = this.markup.doc.createElement("div");
1397 tagState.classList.add("tag-state");
1398 line.appendChild(tagState);
1399
1400 line.appendChild(closingTag.cloneNode(true));
1401
1402 this.closeTagLine = line;
1403 }
1404 this.elt.appendChild(this.closeTagLine);
1405 }
1406 }
1407 this.elt.classList.remove("collapsed");
1408 this.expander.setAttribute("open", "");
1409 this.hovered = false;
1410 } else if (!aValue) {
1411 if (this.editor instanceof ElementEditor && this.closeTagLine) {
1412 this.elt.removeChild(this.closeTagLine);
1413 }
1414 this.elt.classList.add("collapsed");
1415 this.expander.removeAttribute("open");
1416 }
1417 },
1418
1419 _onToggle: function(event) {
1420 this.markup.navigate(this);
1421 if(this.hasChildren) {
1422 this.markup.setNodeExpanded(this.node, !this.expanded, event.altKey);
1423 }
1424 event.stopPropagation();
1425 },
1426
1427 _onMouseDown: function(event) {
1428 let target = event.target;
1429
1430 // Target may be a resource link (generated by the output-parser)
1431 if (target.nodeName === "a") {
1432 event.stopPropagation();
1433 event.preventDefault();
1434 let browserWin = this.markup._inspector.target
1435 .tab.ownerDocument.defaultView;
1436 browserWin.openUILinkIn(target.href, "tab");
1437 }
1438 // Or it may be the "show more nodes" button (which already has its onclick)
1439 // Else, it's the container itself
1440 else if (target.nodeName !== "button") {
1441 this.hovered = false;
1442 this.markup.navigate(this);
1443 event.stopPropagation();
1444 }
1445 },
1446
1447 /**
1448 * Temporarily flash the container to attract attention.
1449 * Used for markup mutations.
1450 */
1451 flashMutation: function() {
1452 if (!this.selected) {
1453 let contentWin = this.markup._frame.contentWindow;
1454 this.flashed = true;
1455 if (this._flashMutationTimer) {
1456 contentWin.clearTimeout(this._flashMutationTimer);
1457 this._flashMutationTimer = null;
1458 }
1459 this._flashMutationTimer = contentWin.setTimeout(() => {
1460 this.flashed = false;
1461 }, CONTAINER_FLASHING_DURATION);
1462 }
1463 },
1464
1465 set flashed(aValue) {
1466 if (aValue) {
1467 // Make sure the animation class is not here
1468 this.tagState.classList.remove("flash-out");
1469
1470 // Change the background
1471 this.tagState.classList.add("theme-bg-contrast");
1472
1473 // Change the text color
1474 this.editor.elt.classList.add("theme-fg-contrast");
1475 [].forEach.call(
1476 this.editor.elt.querySelectorAll("[class*=theme-fg-color]"),
1477 span => span.classList.add("theme-fg-contrast")
1478 );
1479 } else {
1480 // Add the animation class to smoothly remove the background
1481 this.tagState.classList.add("flash-out");
1482
1483 // Remove the background
1484 this.tagState.classList.remove("theme-bg-contrast");
1485
1486 // Remove the text color
1487 this.editor.elt.classList.remove("theme-fg-contrast");
1488 [].forEach.call(
1489 this.editor.elt.querySelectorAll("[class*=theme-fg-color]"),
1490 span => span.classList.remove("theme-fg-contrast")
1491 );
1492 }
1493 },
1494
1495 _hovered: false,
1496
1497 /**
1498 * Highlight the currently hovered tag + its closing tag if necessary
1499 * (that is if the tag is expanded)
1500 */
1501 set hovered(aValue) {
1502 this.tagState.classList.remove("flash-out");
1503 this._hovered = aValue;
1504 if (aValue) {
1505 if (!this.selected) {
1506 this.tagState.classList.add("theme-bg-darker");
1507 }
1508 if (this.closeTagLine) {
1509 this.closeTagLine.querySelector(".tag-state").classList.add(
1510 "theme-bg-darker");
1511 }
1512 } else {
1513 this.tagState.classList.remove("theme-bg-darker");
1514 if (this.closeTagLine) {
1515 this.closeTagLine.querySelector(".tag-state").classList.remove(
1516 "theme-bg-darker");
1517 }
1518 }
1519 },
1520
1521 /**
1522 * True if the container is visible in the markup tree.
1523 */
1524 get visible() {
1525 return this.elt.getBoundingClientRect().height > 0;
1526 },
1527
1528 /**
1529 * True if the container is currently selected.
1530 */
1531 _selected: false,
1532
1533 get selected() {
1534 return this._selected;
1535 },
1536
1537 set selected(aValue) {
1538 this.tagState.classList.remove("flash-out");
1539 this._selected = aValue;
1540 this.editor.selected = aValue;
1541 if (this._selected) {
1542 this.tagLine.setAttribute("selected", "");
1543 this.tagState.classList.add("theme-selected");
1544 } else {
1545 this.tagLine.removeAttribute("selected");
1546 this.tagState.classList.remove("theme-selected");
1547 }
1548 },
1549
1550 /**
1551 * Update the container's editor to the current state of the
1552 * viewed node.
1553 */
1554 update: function() {
1555 if (this.editor.update) {
1556 this.editor.update();
1557 }
1558 },
1559
1560 /**
1561 * Try to put keyboard focus on the current editor.
1562 */
1563 focus: function() {
1564 let focusable = this.editor.elt.querySelector("[tabindex]");
1565 if (focusable) {
1566 focusable.focus();
1567 }
1568 },
1569
1570 /**
1571 * Get rid of event listeners and references, when the container is no longer
1572 * needed
1573 */
1574 destroy: function() {
1575 // Recursively destroy children containers
1576 let firstChild;
1577 while (firstChild = this.children.firstChild) {
1578 // Not all children of a container are containers themselves
1579 // ("show more nodes" button is one example)
1580 if (firstChild.container) {
1581 firstChild.container.destroy();
1582 }
1583 this.children.removeChild(firstChild);
1584 }
1585
1586 // Remove event listeners
1587 this.elt.removeEventListener("dblclick", this._onToggle, false);
1588 this.elt.removeEventListener("mousedown", this._onMouseDown, false);
1589 this.expander.removeEventListener("click", this._onToggle, false);
1590
1591 // Destroy my editor
1592 this.editor.destroy();
1593 }
1594 };
1595
1596
1597 /**
1598 * Dummy container node used for the root document element.
1599 */
1600 function RootContainer(aMarkupView, aNode) {
1601 this.doc = aMarkupView.doc;
1602 this.elt = this.doc.createElement("ul");
1603 this.elt.container = this;
1604 this.children = this.elt;
1605 this.node = aNode;
1606 this.toString = () => "[root container]";
1607 }
1608
1609 RootContainer.prototype = {
1610 hasChildren: true,
1611 expanded: true,
1612 update: function() {},
1613 destroy: function() {}
1614 };
1615
1616 /**
1617 * Creates an editor for simple nodes.
1618 */
1619 function GenericEditor(aContainer, aNode) {
1620 this.elt = aContainer.doc.createElement("span");
1621 this.elt.className = "editor";
1622 this.elt.textContent = aNode.nodeName;
1623 }
1624
1625 GenericEditor.prototype = {
1626 destroy: function() {}
1627 };
1628
1629 /**
1630 * Creates an editor for a DOCTYPE node.
1631 *
1632 * @param MarkupContainer aContainer The container owning this editor.
1633 * @param DOMNode aNode The node being edited.
1634 */
1635 function DoctypeEditor(aContainer, aNode) {
1636 this.elt = aContainer.doc.createElement("span");
1637 this.elt.className = "editor comment";
1638 this.elt.textContent = '<!DOCTYPE ' + aNode.name +
1639 (aNode.publicId ? ' PUBLIC "' + aNode.publicId + '"': '') +
1640 (aNode.systemId ? ' "' + aNode.systemId + '"' : '') +
1641 '>';
1642 }
1643
1644 DoctypeEditor.prototype = {
1645 destroy: function() {}
1646 };
1647
1648 /**
1649 * Creates a simple text editor node, used for TEXT and COMMENT
1650 * nodes.
1651 *
1652 * @param MarkupContainer aContainer The container owning this editor.
1653 * @param DOMNode aNode The node being edited.
1654 * @param string aTemplate The template id to use to build the editor.
1655 */
1656 function TextEditor(aContainer, aNode, aTemplate) {
1657 this.node = aNode;
1658 this._selected = false;
1659
1660 aContainer.markup.template(aTemplate, this);
1661
1662 editableField({
1663 element: this.value,
1664 stopOnReturn: true,
1665 trigger: "dblclick",
1666 multiline: true,
1667 done: (aVal, aCommit) => {
1668 if (!aCommit) {
1669 return;
1670 }
1671 this.node.getNodeValue().then(longstr => {
1672 longstr.string().then(oldValue => {
1673 longstr.release().then(null, console.error);
1674
1675 aContainer.undo.do(() => {
1676 this.node.setNodeValue(aVal).then(() => {
1677 aContainer.markup.nodeChanged(this.node);
1678 });
1679 }, () => {
1680 this.node.setNodeValue(oldValue).then(() => {
1681 aContainer.markup.nodeChanged(this.node);
1682 })
1683 });
1684 });
1685 });
1686 }
1687 });
1688
1689 this.update();
1690 }
1691
1692 TextEditor.prototype = {
1693 get selected() this._selected,
1694 set selected(aValue) {
1695 if (aValue === this._selected) {
1696 return;
1697 }
1698 this._selected = aValue;
1699 this.update();
1700 },
1701
1702 update: function() {
1703 if (!this.selected || !this.node.incompleteValue) {
1704 let text = this.node.shortValue;
1705 // XXX: internationalize the elliding
1706 if (this.node.incompleteValue) {
1707 text += "…";
1708 }
1709 this.value.textContent = text;
1710 } else {
1711 let longstr = null;
1712 this.node.getNodeValue().then(ret => {
1713 longstr = ret;
1714 return longstr.string();
1715 }).then(str => {
1716 longstr.release().then(null, console.error);
1717 if (this.selected) {
1718 this.value.textContent = str;
1719 }
1720 }).then(null, console.error);
1721 }
1722 },
1723
1724 destroy: function() {}
1725 };
1726
1727 /**
1728 * Creates an editor for an Element node.
1729 *
1730 * @param MarkupContainer aContainer The container owning this editor.
1731 * @param Element aNode The node being edited.
1732 */
1733 function ElementEditor(aContainer, aNode) {
1734 this.doc = aContainer.doc;
1735 this.undo = aContainer.undo;
1736 this.template = aContainer.markup.template.bind(aContainer.markup);
1737 this.container = aContainer;
1738 this.markup = this.container.markup;
1739 this.node = aNode;
1740
1741 this.attrs = {};
1742
1743 // The templates will fill the following properties
1744 this.elt = null;
1745 this.tag = null;
1746 this.closeTag = null;
1747 this.attrList = null;
1748 this.newAttr = null;
1749 this.closeElt = null;
1750
1751 // Create the main editor
1752 this.template("element", this);
1753
1754 if (aNode.isLocal_toBeDeprecated()) {
1755 this.rawNode = aNode.rawNode();
1756 }
1757
1758 // Make the tag name editable (unless this is a remote node or
1759 // a document element)
1760 if (this.rawNode && !aNode.isDocumentElement) {
1761 this.tag.setAttribute("tabindex", "0");
1762 editableField({
1763 element: this.tag,
1764 trigger: "dblclick",
1765 stopOnReturn: true,
1766 done: this.onTagEdit.bind(this),
1767 });
1768 }
1769
1770 // Make the new attribute space editable.
1771 editableField({
1772 element: this.newAttr,
1773 trigger: "dblclick",
1774 stopOnReturn: true,
1775 contentType: InplaceEditor.CONTENT_TYPES.CSS_MIXED,
1776 popup: this.markup.popup,
1777 done: (aVal, aCommit) => {
1778 if (!aCommit) {
1779 return;
1780 }
1781
1782 try {
1783 let doMods = this._startModifyingAttributes();
1784 let undoMods = this._startModifyingAttributes();
1785 this._applyAttributes(aVal, null, doMods, undoMods);
1786 this.undo.do(() => {
1787 doMods.apply();
1788 }, function() {
1789 undoMods.apply();
1790 });
1791 } catch(x) {
1792 console.error(x);
1793 }
1794 }
1795 });
1796
1797 let tagName = this.node.nodeName.toLowerCase();
1798 this.tag.textContent = tagName;
1799 this.closeTag.textContent = tagName;
1800
1801 this.update();
1802 }
1803
1804 ElementEditor.prototype = {
1805 /**
1806 * Update the state of the editor from the node.
1807 */
1808 update: function() {
1809 let attrs = this.node.attributes;
1810 if (!attrs) {
1811 return;
1812 }
1813
1814 // Hide all the attribute editors, they'll be re-shown if they're
1815 // still applicable. Don't update attributes that are being
1816 // actively edited.
1817 let attrEditors = this.attrList.querySelectorAll(".attreditor");
1818 for (let i = 0; i < attrEditors.length; i++) {
1819 if (!attrEditors[i].inplaceEditor) {
1820 attrEditors[i].style.display = "none";
1821 }
1822 }
1823
1824 // Get the attribute editor for each attribute that exists on
1825 // the node and show it.
1826 for (let attr of attrs) {
1827 let attribute = this._createAttribute(attr);
1828 if (!attribute.inplaceEditor) {
1829 attribute.style.removeProperty("display");
1830 }
1831 }
1832 },
1833
1834 _startModifyingAttributes: function() {
1835 return this.node.startModifyingAttributes();
1836 },
1837
1838 /**
1839 * Get the element used for one of the attributes of this element
1840 * @param string attrName The name of the attribute to get the element for
1841 * @return DOMElement
1842 */
1843 getAttributeElement: function(attrName) {
1844 return this.attrList.querySelector(
1845 ".attreditor[data-attr=" + attrName + "] .attr-value");
1846 },
1847
1848 _createAttribute: function(aAttr, aBefore = null) {
1849 // Create the template editor, which will save some variables here.
1850 let data = {
1851 attrName: aAttr.name,
1852 };
1853 this.template("attribute", data);
1854 var {attr, inner, name, val} = data;
1855
1856 // Double quotes need to be handled specially to prevent DOMParser failing.
1857 // name="v"a"l"u"e" when editing -> name='v"a"l"u"e"'
1858 // name="v'a"l'u"e" when editing -> name="v'a&quot;l'u&quot;e"
1859 let editValueDisplayed = aAttr.value || "";
1860 let hasDoubleQuote = editValueDisplayed.contains('"');
1861 let hasSingleQuote = editValueDisplayed.contains("'");
1862 let initial = aAttr.name + '="' + editValueDisplayed + '"';
1863
1864 // Can't just wrap value with ' since the value contains both " and '.
1865 if (hasDoubleQuote && hasSingleQuote) {
1866 editValueDisplayed = editValueDisplayed.replace(/\"/g, "&quot;");
1867 initial = aAttr.name + '="' + editValueDisplayed + '"';
1868 }
1869
1870 // Wrap with ' since there are no single quotes in the attribute value.
1871 if (hasDoubleQuote && !hasSingleQuote) {
1872 initial = aAttr.name + "='" + editValueDisplayed + "'";
1873 }
1874
1875 // Make the attribute editable.
1876 editableField({
1877 element: inner,
1878 trigger: "dblclick",
1879 stopOnReturn: true,
1880 selectAll: false,
1881 initial: initial,
1882 contentType: InplaceEditor.CONTENT_TYPES.CSS_MIXED,
1883 popup: this.markup.popup,
1884 start: (aEditor, aEvent) => {
1885 // If the editing was started inside the name or value areas,
1886 // select accordingly.
1887 if (aEvent && aEvent.target === name) {
1888 aEditor.input.setSelectionRange(0, name.textContent.length);
1889 } else if (aEvent && aEvent.target === val) {
1890 let length = editValueDisplayed.length;
1891 let editorLength = aEditor.input.value.length;
1892 let start = editorLength - (length + 1);
1893 aEditor.input.setSelectionRange(start, start + length);
1894 } else {
1895 aEditor.input.select();
1896 }
1897 },
1898 done: (aVal, aCommit) => {
1899 if (!aCommit || aVal === initial) {
1900 return;
1901 }
1902
1903 let doMods = this._startModifyingAttributes();
1904 let undoMods = this._startModifyingAttributes();
1905
1906 // Remove the attribute stored in this editor and re-add any attributes
1907 // parsed out of the input element. Restore original attribute if
1908 // parsing fails.
1909 try {
1910 this._saveAttribute(aAttr.name, undoMods);
1911 doMods.removeAttribute(aAttr.name);
1912 this._applyAttributes(aVal, attr, doMods, undoMods);
1913 this.undo.do(() => {
1914 doMods.apply();
1915 }, () => {
1916 undoMods.apply();
1917 })
1918 } catch(ex) {
1919 console.error(ex);
1920 }
1921 }
1922 });
1923
1924 // Figure out where we should place the attribute.
1925 let before = aBefore;
1926 if (aAttr.name == "id") {
1927 before = this.attrList.firstChild;
1928 } else if (aAttr.name == "class") {
1929 let idNode = this.attrs["id"];
1930 before = idNode ? idNode.nextSibling : this.attrList.firstChild;
1931 }
1932 this.attrList.insertBefore(attr, before);
1933
1934 // Remove the old version of this attribute from the DOM.
1935 let oldAttr = this.attrs[aAttr.name];
1936 if (oldAttr && oldAttr.parentNode) {
1937 oldAttr.parentNode.removeChild(oldAttr);
1938 }
1939
1940 this.attrs[aAttr.name] = attr;
1941
1942 let collapsedValue;
1943 if (aAttr.value.match(COLLAPSE_DATA_URL_REGEX)) {
1944 collapsedValue = truncateString(aAttr.value, COLLAPSE_DATA_URL_LENGTH);
1945 } else {
1946 collapsedValue = truncateString(aAttr.value, COLLAPSE_ATTRIBUTE_LENGTH);
1947 }
1948
1949 name.textContent = aAttr.name;
1950 val.textContent = collapsedValue;
1951
1952 return attr;
1953 },
1954
1955 /**
1956 * Parse a user-entered attribute string and apply the resulting
1957 * attributes to the node. This operation is undoable.
1958 *
1959 * @param string aValue the user-entered value.
1960 * @param Element aAttrNode the attribute editor that created this
1961 * set of attributes, used to place new attributes where the
1962 * user put them.
1963 */
1964 _applyAttributes: function(aValue, aAttrNode, aDoMods, aUndoMods) {
1965 let attrs = parseAttributeValues(aValue, this.doc);
1966 for (let attr of attrs) {
1967 // Create an attribute editor next to the current attribute if needed.
1968 this._createAttribute(attr, aAttrNode ? aAttrNode.nextSibling : null);
1969 this._saveAttribute(attr.name, aUndoMods);
1970 aDoMods.setAttribute(attr.name, attr.value);
1971 }
1972 },
1973
1974 /**
1975 * Saves the current state of the given attribute into an attribute
1976 * modification list.
1977 */
1978 _saveAttribute: function(aName, aUndoMods) {
1979 let node = this.node;
1980 if (node.hasAttribute(aName)) {
1981 let oldValue = node.getAttribute(aName);
1982 aUndoMods.setAttribute(aName, oldValue);
1983 } else {
1984 aUndoMods.removeAttribute(aName);
1985 }
1986 },
1987
1988 /**
1989 * Called when the tag name editor has is done editing.
1990 */
1991 onTagEdit: function(aVal, aCommit) {
1992 if (!aCommit || aVal == this.rawNode.tagName) {
1993 return;
1994 }
1995
1996 // Create a new element with the same attributes as the
1997 // current element and prepare to replace the current node
1998 // with it.
1999 try {
2000 var newElt = nodeDocument(this.rawNode).createElement(aVal);
2001 } catch(x) {
2002 // Failed to create a new element with that tag name, ignore
2003 // the change.
2004 return;
2005 }
2006
2007 let attrs = this.rawNode.attributes;
2008
2009 for (let i = 0 ; i < attrs.length; i++) {
2010 newElt.setAttribute(attrs[i].name, attrs[i].value);
2011 }
2012 let newFront = this.markup.walker.frontForRawNode(newElt);
2013 let newContainer = this.markup.importNode(newFront);
2014
2015 // Retain the two nodes we care about here so we can undo.
2016 let walker = this.markup.walker;
2017 promise.all([
2018 walker.retainNode(newFront), walker.retainNode(this.node)
2019 ]).then(() => {
2020 function swapNodes(aOld, aNew) {
2021 aOld.parentNode.insertBefore(aNew, aOld);
2022 while (aOld.firstChild) {
2023 aNew.appendChild(aOld.firstChild);
2024 }
2025 aOld.parentNode.removeChild(aOld);
2026 }
2027
2028 this.undo.do(() => {
2029 swapNodes(this.rawNode, newElt);
2030 this.markup.setNodeExpanded(newFront, this.container.expanded);
2031 if (this.container.selected) {
2032 this.markup.navigate(newContainer);
2033 }
2034 }, () => {
2035 swapNodes(newElt, this.rawNode);
2036 this.markup.setNodeExpanded(this.node, newContainer.expanded);
2037 if (newContainer.selected) {
2038 this.markup.navigate(this.container);
2039 }
2040 });
2041 }).then(null, console.error);
2042 },
2043
2044 destroy: function() {}
2045 };
2046
2047 function nodeDocument(node) {
2048 return node.ownerDocument ||
2049 (node.nodeType == Ci.nsIDOMNode.DOCUMENT_NODE ? node : null);
2050 }
2051
2052 function truncateString(str, maxLength) {
2053 if (str.length <= maxLength) {
2054 return str;
2055 }
2056
2057 return str.substring(0, Math.ceil(maxLength / 2)) +
2058 "…" +
2059 str.substring(str.length - Math.floor(maxLength / 2));
2060 }
2061 /**
2062 * Parse attribute names and values from a string.
2063 *
2064 * @param {String} attr
2065 * The input string for which names/values are to be parsed.
2066 * @param {HTMLDocument} doc
2067 * A document that can be used to test valid attributes.
2068 * @return {Array}
2069 * An array of attribute names and their values.
2070 */
2071 function parseAttributeValues(attr, doc) {
2072 attr = attr.trim();
2073
2074 // Handle bad user inputs by appending a " or ' if it fails to parse without them.
2075 let el = DOMParser.parseFromString("<div " + attr + "></div>", "text/html").body.childNodes[0] ||
2076 DOMParser.parseFromString("<div " + attr + "\"></div>", "text/html").body.childNodes[0] ||
2077 DOMParser.parseFromString("<div " + attr + "'></div>", "text/html").body.childNodes[0];
2078 let div = doc.createElement("div");
2079
2080 let attributes = [];
2081 for (let attribute of el.attributes) {
2082 // Try to set on an element in the document, throws exception on bad input.
2083 // Prevents InvalidCharacterError - "String contains an invalid character".
2084 try {
2085 div.setAttribute(attribute.name, attribute.value);
2086 attributes.push({
2087 name: attribute.name,
2088 value: attribute.value
2089 });
2090 }
2091 catch(e) { }
2092 }
2093
2094 // Attributes return from DOMParser in reverse order from how they are entered.
2095 return attributes.reverse();
2096 }
2097
2098 loader.lazyGetter(MarkupView.prototype, "strings", () => Services.strings.createBundle(
2099 "chrome://browser/locale/devtools/inspector.properties"
2100 ));
2101
2102 XPCOMUtils.defineLazyGetter(this, "clipboardHelper", function() {
2103 return Cc["@mozilla.org/widget/clipboardhelper;1"].
2104 getService(Ci.nsIClipboardHelper);
2105 });

mercurial