browser/devtools/layoutview/view.js

changeset 0
6474c204b198
equal deleted inserted replaced
-1:000000000000 0:761eee76a708
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 "use strict";
8
9 const Cu = Components.utils;
10 const Ci = Components.interfaces;
11 const Cc = Components.classes;
12
13 Cu.import("resource://gre/modules/Services.jsm");
14 Cu.import("resource://gre/modules/Task.jsm");
15 Cu.import("resource://gre/modules/devtools/Loader.jsm");
16 Cu.import("resource://gre/modules/devtools/Console.jsm");
17
18 const {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {});
19 const {InplaceEditor, editableItem} = devtools.require("devtools/shared/inplace-editor");
20 const {parseDeclarations} = devtools.require("devtools/styleinspector/css-parsing-utils");
21
22 const NUMERIC = /^-?[\d\.]+$/;
23 const LONG_TEXT_ROTATE_LIMIT = 3;
24
25 /**
26 * An instance of EditingSession tracks changes that have been made during the
27 * modification of box model values. All of these changes can be reverted by
28 * calling revert.
29 *
30 * @param doc A DOM document that can be used to test style rules.
31 * @param rules An array of the style rules defined for the node being edited.
32 * These should be in order of priority, least important first.
33 */
34 function EditingSession(doc, rules) {
35 this._doc = doc;
36 this._rules = rules;
37 this._modifications = new Map();
38 }
39
40 EditingSession.prototype = {
41 /**
42 * Gets the value of a single property from the CSS rule.
43 *
44 * @param rule The CSS rule
45 * @param property The name of the property
46 */
47 getPropertyFromRule: function(rule, property) {
48 let dummyStyle = this._element.style;
49
50 dummyStyle.cssText = rule.cssText;
51 return dummyStyle.getPropertyValue(property);
52 },
53
54 /**
55 * Returns the current value for a property as a string or the empty string if
56 * no style rules affect the property.
57 *
58 * @param property The name of the property as a string
59 */
60 getProperty: function(property) {
61 // Create a hidden element for getPropertyFromRule to use
62 let div = this._doc.createElement("div");
63 div.setAttribute("style", "display: none");
64 this._doc.body.appendChild(div);
65 this._element = this._doc.createElement("p");
66 div.appendChild(this._element);
67
68 // As the rules are in order of priority we can just iterate until we find
69 // the first that defines a value for the property and return that.
70 for (let rule of this._rules) {
71 let value = this.getPropertyFromRule(rule, property);
72 if (value !== "") {
73 div.remove();
74 return value;
75 }
76 }
77 div.remove();
78 return "";
79 },
80
81 /**
82 * Sets a number of properties on the node. Returns a promise that will be
83 * resolved when the modifications are complete.
84 *
85 * @param properties An array of properties, each is an object with name and
86 * value properties. If the value is "" then the property
87 * is removed.
88 */
89 setProperties: function(properties) {
90 let modifications = this._rules[0].startModifyingProperties();
91
92 for (let property of properties) {
93 if (!this._modifications.has(property.name))
94 this._modifications.set(property.name, this.getPropertyFromRule(this._rules[0], property.name));
95
96 if (property.value == "")
97 modifications.removeProperty(property.name);
98 else
99 modifications.setProperty(property.name, property.value, "");
100 }
101
102 return modifications.apply().then(null, console.error);
103 },
104
105 /**
106 * Reverts all of the property changes made by this instance. Returns a
107 * promise that will be resolved when complete.
108 */
109 revert: function() {
110 let modifications = this._rules[0].startModifyingProperties();
111
112 for (let [property, value] of this._modifications) {
113 if (value != "")
114 modifications.setProperty(property, value, "");
115 else
116 modifications.removeProperty(property);
117 }
118
119 return modifications.apply().then(null, console.error);
120 }
121 };
122
123 function LayoutView(aInspector, aWindow)
124 {
125 this.inspector = aInspector;
126
127 // <browser> is not always available (for Chrome targets for example)
128 if (this.inspector.target.tab) {
129 this.browser = aInspector.target.tab.linkedBrowser;
130 }
131
132 this.doc = aWindow.document;
133 this.sizeLabel = this.doc.querySelector(".size > span");
134 this.sizeHeadingLabel = this.doc.getElementById("element-size");
135
136 this.init();
137 }
138
139 LayoutView.prototype = {
140 init: function LV_init() {
141 this.update = this.update.bind(this);
142 this.onNewNode = this.onNewNode.bind(this);
143 this.onNewSelection = this.onNewSelection.bind(this);
144 this.inspector.selection.on("new-node-front", this.onNewSelection);
145 this.inspector.sidebar.on("layoutview-selected", this.onNewNode);
146
147 // Store for the different dimensions of the node.
148 // 'selector' refers to the element that holds the value in view.xhtml;
149 // 'property' is what we are measuring;
150 // 'value' is the computed dimension, computed in update().
151 this.map = {
152 position: {selector: "#element-position",
153 property: "position",
154 value: undefined},
155 marginTop: {selector: ".margin.top > span",
156 property: "margin-top",
157 value: undefined},
158 marginBottom: {selector: ".margin.bottom > span",
159 property: "margin-bottom",
160 value: undefined},
161 // margin-left is a shorthand for some internal properties,
162 // margin-left-ltr-source and margin-left-rtl-source for example. The
163 // real margin value we want is in margin-left-value
164 marginLeft: {selector: ".margin.left > span",
165 property: "margin-left",
166 realProperty: "margin-left-value",
167 value: undefined},
168 // margin-right behaves the same as margin-left
169 marginRight: {selector: ".margin.right > span",
170 property: "margin-right",
171 realProperty: "margin-right-value",
172 value: undefined},
173 paddingTop: {selector: ".padding.top > span",
174 property: "padding-top",
175 value: undefined},
176 paddingBottom: {selector: ".padding.bottom > span",
177 property: "padding-bottom",
178 value: undefined},
179 // padding-left behaves the same as margin-left
180 paddingLeft: {selector: ".padding.left > span",
181 property: "padding-left",
182 realProperty: "padding-left-value",
183 value: undefined},
184 // padding-right behaves the same as margin-left
185 paddingRight: {selector: ".padding.right > span",
186 property: "padding-right",
187 realProperty: "padding-right-value",
188 value: undefined},
189 borderTop: {selector: ".border.top > span",
190 property: "border-top-width",
191 value: undefined},
192 borderBottom: {selector: ".border.bottom > span",
193 property: "border-bottom-width",
194 value: undefined},
195 borderLeft: {selector: ".border.left > span",
196 property: "border-left-width",
197 value: undefined},
198 borderRight: {selector: ".border.right > span",
199 property: "border-right-width",
200 value: undefined},
201 };
202
203 // Make each element the dimensions editable
204 for (let i in this.map) {
205 if (i == "position")
206 continue;
207
208 let dimension = this.map[i];
209 editableItem({ element: this.doc.querySelector(dimension.selector) }, (element, event) => {
210 this.initEditor(element, event, dimension);
211 });
212 }
213
214 this.onNewNode();
215 },
216
217 /**
218 * Called when the user clicks on one of the editable values in the layoutview
219 */
220 initEditor: function LV_initEditor(element, event, dimension) {
221 let { property, realProperty } = dimension;
222 if (!realProperty)
223 realProperty = property;
224 let session = new EditingSession(document, this.elementRules);
225 let initialValue = session.getProperty(realProperty);
226
227 let editor = new InplaceEditor({
228 element: element,
229 initial: initialValue,
230
231 start: (editor) => {
232 editor.elt.parentNode.classList.add("editing");
233 },
234
235 change: (value) => {
236 if (NUMERIC.test(value))
237 value += "px";
238 let properties = [
239 { name: property, value: value }
240 ]
241
242 if (property.substring(0, 7) == "border-") {
243 let bprop = property.substring(0, property.length - 5) + "style";
244 let style = session.getProperty(bprop);
245 if (!style || style == "none" || style == "hidden")
246 properties.push({ name: bprop, value: "solid" });
247 }
248
249 session.setProperties(properties);
250 },
251
252 done: (value, commit) => {
253 editor.elt.parentNode.classList.remove("editing");
254 if (!commit)
255 session.revert();
256 }
257 }, event);
258 },
259
260 /**
261 * Is the layoutview visible in the sidebar?
262 */
263 isActive: function LV_isActive() {
264 return this.inspector.sidebar.getCurrentTabID() == "layoutview";
265 },
266
267 /**
268 * Destroy the nodes. Remove listeners.
269 */
270 destroy: function LV_destroy() {
271 this.inspector.sidebar.off("layoutview-selected", this.onNewNode);
272 this.inspector.selection.off("new-node-front", this.onNewSelection);
273 if (this.browser) {
274 this.browser.removeEventListener("MozAfterPaint", this.update, true);
275 }
276 this.sizeHeadingLabel = null;
277 this.sizeLabel = null;
278 this.inspector = null;
279 this.doc = null;
280 },
281
282 /**
283 * Selection 'new-node-front' event handler.
284 */
285 onNewSelection: function() {
286 let done = this.inspector.updating("layoutview");
287 this.onNewNode().then(done, (err) => { console.error(err); done() });
288 },
289
290 onNewNode: function LV_onNewNode() {
291 if (this.isActive() &&
292 this.inspector.selection.isConnected() &&
293 this.inspector.selection.isElementNode()) {
294 this.undim();
295 } else {
296 this.dim();
297 }
298 return this.update();
299 },
300
301 /**
302 * Hide the layout boxes. No node are selected.
303 */
304 dim: function LV_dim() {
305 if (this.browser) {
306 this.browser.removeEventListener("MozAfterPaint", this.update, true);
307 }
308 this.trackingPaint = false;
309 this.doc.body.classList.add("dim");
310 this.dimmed = true;
311 },
312
313 /**
314 * Show the layout boxes. A node is selected.
315 */
316 undim: function LV_undim() {
317 if (!this.trackingPaint) {
318 if (this.browser) {
319 this.browser.addEventListener("MozAfterPaint", this.update, true);
320 }
321 this.trackingPaint = true;
322 }
323 this.doc.body.classList.remove("dim");
324 this.dimmed = false;
325 },
326
327 /**
328 * Compute the dimensions of the node and update the values in
329 * the layoutview/view.xhtml document. Returns a promise that will be resolved
330 * when complete.
331 */
332 update: function LV_update() {
333 let lastRequest = Task.spawn((function*() {
334 if (!this.isActive() ||
335 !this.inspector.selection.isConnected() ||
336 !this.inspector.selection.isElementNode()) {
337 return;
338 }
339
340 let node = this.inspector.selection.nodeFront;
341 let layout = yield this.inspector.pageStyle.getLayout(node, {
342 autoMargins: !this.dimmed
343 });
344 let styleEntries = yield this.inspector.pageStyle.getApplied(node, {});
345
346 // If a subsequent request has been made, wait for that one instead.
347 if (this._lastRequest != lastRequest) {
348 return this._lastRequest;
349 }
350
351 this._lastRequest = null;
352 let width = layout.width;
353 let height = layout.height;
354 let newLabel = width + "x" + height;
355 if (this.sizeHeadingLabel.textContent != newLabel) {
356 this.sizeHeadingLabel.textContent = newLabel;
357 }
358
359 // If the view is dimmed, no need to do anything more.
360 if (this.dimmed) {
361 this.inspector.emit("layoutview-updated");
362 return null;
363 }
364
365 for (let i in this.map) {
366 let property = this.map[i].property;
367 if (!(property in layout)) {
368 // Depending on the actor version, some properties
369 // might be missing.
370 continue;
371 }
372 let parsedValue = parseInt(layout[property]);
373 if (Number.isNaN(parsedValue)) {
374 // Not a number. We use the raw string.
375 // Useful for "position" for example.
376 this.map[i].value = layout[property];
377 } else {
378 this.map[i].value = parsedValue;
379 }
380 }
381
382 let margins = layout.autoMargins;
383 if ("top" in margins) this.map.marginTop.value = "auto";
384 if ("right" in margins) this.map.marginRight.value = "auto";
385 if ("bottom" in margins) this.map.marginBottom.value = "auto";
386 if ("left" in margins) this.map.marginLeft.value = "auto";
387
388 for (let i in this.map) {
389 let selector = this.map[i].selector;
390 let span = this.doc.querySelector(selector);
391 if (span.textContent.length > 0 &&
392 span.textContent == this.map[i].value) {
393 continue;
394 }
395 span.textContent = this.map[i].value;
396 this.manageOverflowingText(span);
397 }
398
399 width -= this.map.borderLeft.value + this.map.borderRight.value +
400 this.map.paddingLeft.value + this.map.paddingRight.value;
401
402 height -= this.map.borderTop.value + this.map.borderBottom.value +
403 this.map.paddingTop.value + this.map.paddingBottom.value;
404
405 let newValue = width + "x" + height;
406 if (this.sizeLabel.textContent != newValue) {
407 this.sizeLabel.textContent = newValue;
408 }
409
410 this.elementRules = [e.rule for (e of styleEntries)];
411
412 this.inspector.emit("layoutview-updated");
413 }).bind(this)).then(null, console.error);
414
415 return this._lastRequest = lastRequest;
416 },
417
418 showBoxModel: function(options={}) {
419 let toolbox = this.inspector.toolbox;
420 let nodeFront = this.inspector.selection.nodeFront;
421
422 toolbox.highlighterUtils.highlightNodeFront(nodeFront, options);
423 },
424
425 hideBoxModel: function() {
426 let toolbox = this.inspector.toolbox;
427
428 toolbox.highlighterUtils.unhighlight();
429 },
430
431 manageOverflowingText: function(span) {
432 let classList = span.parentNode.classList;
433
434 if (classList.contains("left") || classList.contains("right")) {
435 let force = span.textContent.length > LONG_TEXT_ROTATE_LIMIT;
436 classList.toggle("rotate", force);
437 }
438 }
439 };
440
441 let elts;
442 let tooltip;
443
444 let onmouseover = function(e) {
445 let region = e.target.getAttribute("data-box");
446
447 tooltip.textContent = e.target.getAttribute("tooltip");
448 this.layoutview.showBoxModel({region: region});
449
450 return false;
451 }.bind(window);
452
453 let onmouseout = function(e) {
454 tooltip.textContent = "";
455 this.layoutview.hideBoxModel();
456
457 return false;
458 }.bind(window);
459
460 window.setPanel = function(panel) {
461 this.layoutview = new LayoutView(panel, window);
462
463 // Tooltip mechanism
464 elts = document.querySelectorAll("*[tooltip]");
465 tooltip = document.querySelector(".tooltip");
466 for (let i = 0; i < elts.length; i++) {
467 let elt = elts[i];
468 elt.addEventListener("mouseover", onmouseover, true);
469 elt.addEventListener("mouseout", onmouseout, true);
470 }
471
472 // Mark document as RTL or LTR:
473 let chromeReg = Cc["@mozilla.org/chrome/chrome-registry;1"].
474 getService(Ci.nsIXULChromeRegistry);
475 let dir = chromeReg.isLocaleRTL("global");
476 document.body.setAttribute("dir", dir ? "rtl" : "ltr");
477
478 window.parent.postMessage("layoutview-ready", "*");
479 };
480
481 window.onunload = function() {
482 this.layoutview.destroy();
483 if (elts) {
484 for (let i = 0; i < elts.length; i++) {
485 let elt = elts[i];
486 elt.removeEventListener("mouseover", onmouseover, true);
487 elt.removeEventListener("mouseout", onmouseout, true);
488 }
489 }
490 };

mercurial