|
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 }; |