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