browser/devtools/shared/DeveloperToolbar.jsm

changeset 0
6474c204b198
equal deleted inserted replaced
-1:000000000000 0:ee79a5ee6abe
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5 "use strict";
6
7 this.EXPORTED_SYMBOLS = [ "DeveloperToolbar", "CommandUtils" ];
8
9 const NS_XHTML = "http://www.w3.org/1999/xhtml";
10 const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
11 const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
12
13 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
14 Cu.import("resource://gre/modules/Services.jsm");
15
16 const { require, TargetFactory } = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools;
17
18 const Node = Ci.nsIDOMNode;
19
20 XPCOMUtils.defineLazyModuleGetter(this, "console",
21 "resource://gre/modules/devtools/Console.jsm");
22
23 XPCOMUtils.defineLazyModuleGetter(this, "PluralForm",
24 "resource://gre/modules/PluralForm.jsm");
25
26 XPCOMUtils.defineLazyModuleGetter(this, "EventEmitter",
27 "resource://gre/modules/devtools/event-emitter.js");
28
29 XPCOMUtils.defineLazyGetter(this, "prefBranch", function() {
30 let prefService = Cc["@mozilla.org/preferences-service;1"]
31 .getService(Ci.nsIPrefService);
32 return prefService.getBranch(null)
33 .QueryInterface(Ci.nsIPrefBranch2);
34 });
35
36 XPCOMUtils.defineLazyGetter(this, "toolboxStrings", function () {
37 return Services.strings.createBundle("chrome://browser/locale/devtools/toolbox.properties");
38 });
39
40 const Telemetry = require("devtools/shared/telemetry");
41
42 // This lazy getter is needed to prevent a require loop
43 XPCOMUtils.defineLazyGetter(this, "gcli", () => {
44 let gcli = require("gcli/index");
45 require("devtools/commandline/commands-index");
46 gcli.load();
47 return gcli;
48 });
49
50 Object.defineProperty(this, "ConsoleServiceListener", {
51 get: function() {
52 return require("devtools/toolkit/webconsole/utils").ConsoleServiceListener;
53 },
54 configurable: true,
55 enumerable: true
56 });
57
58 const promise = Cu.import('resource://gre/modules/Promise.jsm', {}).Promise;
59
60 /**
61 * A collection of utilities to help working with commands
62 */
63 let CommandUtils = {
64 /**
65 * Utility to ensure that things are loaded in the correct order
66 */
67 createRequisition: function(environment) {
68 let temp = gcli.createDisplay; // Ensure GCLI is loaded
69 let Requisition = require("gcli/cli").Requisition
70 return new Requisition({ environment: environment });
71 },
72
73 /**
74 * Read a toolbarSpec from preferences
75 * @param pref The name of the preference to read
76 */
77 getCommandbarSpec: function(pref) {
78 let value = prefBranch.getComplexValue(pref, Ci.nsISupportsString).data;
79 return JSON.parse(value);
80 },
81
82 /**
83 * A toolbarSpec is an array of buttonSpecs. A buttonSpec is an array of
84 * strings each of which is a GCLI command (including args if needed).
85 *
86 * Warning: this method uses the unload event of the window that owns the
87 * buttons that are of type checkbox. this means that we don't properly
88 * unregister event handlers until the window is destroyed.
89 */
90 createButtons: function(toolbarSpec, target, document, requisition) {
91 let reply = [];
92
93 toolbarSpec.forEach(function(buttonSpec) {
94 let button = document.createElement("toolbarbutton");
95 reply.push(button);
96
97 if (typeof buttonSpec == "string") {
98 buttonSpec = { typed: buttonSpec };
99 }
100 // Ask GCLI to parse the typed string (doesn't execute it)
101 requisition.update(buttonSpec.typed);
102
103 // Ignore invalid commands
104 let command = requisition.commandAssignment.value;
105 if (command == null) {
106 // TODO: Have a broken icon
107 // button.icon = 'Broken';
108 button.setAttribute("label", "X");
109 button.setAttribute("tooltip", "Unknown command: " + buttonSpec.typed);
110 button.setAttribute("disabled", "true");
111 }
112 else {
113 if (command.buttonId != null) {
114 button.id = command.buttonId;
115 }
116 if (command.buttonClass != null) {
117 button.className = command.buttonClass;
118 }
119 if (command.tooltipText != null) {
120 button.setAttribute("tooltiptext", command.tooltipText);
121 }
122 else if (command.description != null) {
123 button.setAttribute("tooltiptext", command.description);
124 }
125
126 button.addEventListener("click", function() {
127 requisition.update(buttonSpec.typed);
128 //if (requisition.getStatus() == Status.VALID) {
129 requisition.exec();
130 /*
131 }
132 else {
133 console.error('incomplete commands not yet supported');
134 }
135 */
136 }, false);
137
138 // Allow the command button to be toggleable
139 if (command.state) {
140 button.setAttribute("autocheck", false);
141 let onChange = function(event, eventTab) {
142 if (eventTab == target.tab) {
143 if (command.state.isChecked(target)) {
144 button.setAttribute("checked", true);
145 }
146 else if (button.hasAttribute("checked")) {
147 button.removeAttribute("checked");
148 }
149 }
150 };
151 command.state.onChange(target, onChange);
152 onChange(null, target.tab);
153 document.defaultView.addEventListener("unload", function() {
154 command.state.offChange(target, onChange);
155 }, false);
156 }
157 }
158 });
159
160 requisition.update('');
161
162 return reply;
163 },
164
165 /**
166 * A helper function to create the environment object that is passed to
167 * GCLI commands.
168 * @param targetContainer An object containing a 'target' property which
169 * reflects the current debug target
170 */
171 createEnvironment: function(container, targetProperty='target') {
172 if (container[targetProperty].supports == null) {
173 throw new Error('Missing target');
174 }
175
176 return {
177 get target() {
178 if (container[targetProperty].supports == null) {
179 throw new Error('Removed target');
180 }
181
182 return container[targetProperty];
183 },
184
185 get chromeWindow() {
186 return this.target.tab.ownerDocument.defaultView;
187 },
188
189 get chromeDocument() {
190 return this.chromeWindow.document;
191 },
192
193 get window() {
194 return this.chromeWindow.getBrowser().selectedTab.linkedBrowser.contentWindow;
195 },
196
197 get document() {
198 return this.window.document;
199 }
200 };
201 },
202 };
203
204 this.CommandUtils = CommandUtils;
205
206 /**
207 * Due to a number of panel bugs we need a way to check if we are running on
208 * Linux. See the comments for TooltipPanel and OutputPanel for further details.
209 *
210 * When bug 780102 is fixed all isLinux checks can be removed and we can revert
211 * to using panels.
212 */
213 XPCOMUtils.defineLazyGetter(this, "isLinux", function() {
214 return OS == "Linux";
215 });
216
217 XPCOMUtils.defineLazyGetter(this, "OS", function() {
218 let os = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime).OS;
219 return os;
220 });
221
222 /**
223 * A component to manage the global developer toolbar, which contains a GCLI
224 * and buttons for various developer tools.
225 * @param aChromeWindow The browser window to which this toolbar is attached
226 * @param aToolbarElement See browser.xul:<toolbar id="developer-toolbar">
227 */
228 this.DeveloperToolbar = function DeveloperToolbar(aChromeWindow, aToolbarElement)
229 {
230 this._chromeWindow = aChromeWindow;
231
232 this._element = aToolbarElement;
233 this._element.hidden = true;
234 this._doc = this._element.ownerDocument;
235
236 this._telemetry = new Telemetry();
237 this._errorsCount = {};
238 this._warningsCount = {};
239 this._errorListeners = {};
240 this._errorCounterButton = this._doc
241 .getElementById("developer-toolbar-toolbox-button");
242 this._errorCounterButton._defaultTooltipText =
243 this._errorCounterButton.getAttribute("tooltiptext");
244
245 EventEmitter.decorate(this);
246 }
247
248 /**
249 * Inspector notifications dispatched through the nsIObserverService
250 */
251 const NOTIFICATIONS = {
252 /** DeveloperToolbar.show() has been called, and we're working on it */
253 LOAD: "developer-toolbar-load",
254
255 /** DeveloperToolbar.show() has completed */
256 SHOW: "developer-toolbar-show",
257
258 /** DeveloperToolbar.hide() has been called */
259 HIDE: "developer-toolbar-hide"
260 };
261
262 /**
263 * Attach notification constants to the object prototype so tests etc can
264 * use them without needing to import anything
265 */
266 DeveloperToolbar.prototype.NOTIFICATIONS = NOTIFICATIONS;
267
268 Object.defineProperty(DeveloperToolbar.prototype, "target", {
269 get: function() {
270 return TargetFactory.forTab(this._chromeWindow.getBrowser().selectedTab);
271 },
272 enumerable: true
273 });
274
275 /**
276 * Is the toolbar open?
277 */
278 Object.defineProperty(DeveloperToolbar.prototype, 'visible', {
279 get: function DT_visible() {
280 return !this._element.hidden;
281 },
282 enumerable: true
283 });
284
285 let _gSequenceId = 0;
286
287 /**
288 * Getter for a unique ID.
289 */
290 Object.defineProperty(DeveloperToolbar.prototype, 'sequenceId', {
291 get: function DT_visible() {
292 return _gSequenceId++;
293 },
294 enumerable: true
295 });
296
297 /**
298 * Called from browser.xul in response to menu-click or keyboard shortcut to
299 * toggle the toolbar
300 */
301 DeveloperToolbar.prototype.toggle = function() {
302 if (this.visible) {
303 return this.hide();
304 } else {
305 return this.show(true);
306 }
307 };
308
309 /**
310 * Called from browser.xul in response to menu-click or keyboard shortcut to
311 * toggle the toolbar
312 */
313 DeveloperToolbar.prototype.focus = function() {
314 if (this.visible) {
315 this._input.focus();
316 return promise.resolve();
317 } else {
318 return this.show(true);
319 }
320 };
321
322 /**
323 * Called from browser.xul in response to menu-click or keyboard shortcut to
324 * toggle the toolbar
325 */
326 DeveloperToolbar.prototype.focusToggle = function() {
327 if (this.visible) {
328 // If we have focus then the active element is the HTML input contained
329 // inside the xul input element
330 let active = this._chromeWindow.document.activeElement;
331 let position = this._input.compareDocumentPosition(active);
332 if (position & Node.DOCUMENT_POSITION_CONTAINED_BY) {
333 this.hide();
334 }
335 else {
336 this._input.focus();
337 }
338 } else {
339 this.show(true);
340 }
341 };
342
343 /**
344 * Even if the user has not clicked on 'Got it' in the intro, we only show it
345 * once per session.
346 * Warning this is slightly messed up because this.DeveloperToolbar is not the
347 * same as this.DeveloperToolbar when in browser.js context.
348 */
349 DeveloperToolbar.introShownThisSession = false;
350
351 /**
352 * Show the developer toolbar
353 */
354 DeveloperToolbar.prototype.show = function(focus) {
355 if (this._showPromise != null) {
356 return this._showPromise;
357 }
358
359 // hide() is async, so ensure we don't need to wait for hide() to finish
360 var waitPromise = this._hidePromise || promise.resolve();
361
362 this._showPromise = waitPromise.then(() => {
363 Services.prefs.setBoolPref("devtools.toolbar.visible", true);
364
365 this._telemetry.toolOpened("developertoolbar");
366
367 this._notify(NOTIFICATIONS.LOAD);
368
369 this._input = this._doc.querySelector(".gclitoolbar-input-node");
370
371 // Initializing GCLI can only be done when we've got content windows to
372 // write to, so this needs to be done asynchronously.
373 let panelPromises = [
374 TooltipPanel.create(this),
375 OutputPanel.create(this)
376 ];
377 return promise.all(panelPromises).then(panels => {
378 [ this.tooltipPanel, this.outputPanel ] = panels;
379
380 this._doc.getElementById("Tools:DevToolbar").setAttribute("checked", "true");
381
382 this.display = gcli.createDisplay({
383 contentDocument: this._chromeWindow.getBrowser().contentDocument,
384 chromeDocument: this._doc,
385 chromeWindow: this._chromeWindow,
386 hintElement: this.tooltipPanel.hintElement,
387 inputElement: this._input,
388 completeElement: this._doc.querySelector(".gclitoolbar-complete-node"),
389 backgroundElement: this._doc.querySelector(".gclitoolbar-stack-node"),
390 outputDocument: this.outputPanel.document,
391 environment: CommandUtils.createEnvironment(this, "target"),
392 tooltipClass: "gcliterm-tooltip",
393 eval: null,
394 scratchpad: null
395 });
396
397 this.display.focusManager.addMonitoredElement(this.outputPanel._frame);
398 this.display.focusManager.addMonitoredElement(this._element);
399
400 this.display.onVisibilityChange.add(this.outputPanel._visibilityChanged,
401 this.outputPanel);
402 this.display.onVisibilityChange.add(this.tooltipPanel._visibilityChanged,
403 this.tooltipPanel);
404 this.display.onOutput.add(this.outputPanel._outputChanged, this.outputPanel);
405
406 let tabbrowser = this._chromeWindow.getBrowser();
407 tabbrowser.tabContainer.addEventListener("TabSelect", this, false);
408 tabbrowser.tabContainer.addEventListener("TabClose", this, false);
409 tabbrowser.addEventListener("load", this, true);
410 tabbrowser.addEventListener("beforeunload", this, true);
411
412 this._initErrorsCount(tabbrowser.selectedTab);
413 this._devtoolsUnloaded = this._devtoolsUnloaded.bind(this);
414 this._devtoolsLoaded = this._devtoolsLoaded.bind(this);
415 Services.obs.addObserver(this._devtoolsUnloaded, "devtools-unloaded", false);
416 Services.obs.addObserver(this._devtoolsLoaded, "devtools-loaded", false);
417
418 this._element.hidden = false;
419
420 if (focus) {
421 this._input.focus();
422 }
423
424 this._notify(NOTIFICATIONS.SHOW);
425
426 if (!DeveloperToolbar.introShownThisSession) {
427 this.display.maybeShowIntro();
428 DeveloperToolbar.introShownThisSession = true;
429 }
430
431 this._showPromise = null;
432 });
433 });
434
435 return this._showPromise;
436 };
437
438 /**
439 * Hide the developer toolbar.
440 */
441 DeveloperToolbar.prototype.hide = function() {
442 // If we're already in the process of hiding, just use the other promise
443 if (this._hidePromise != null) {
444 return this._hidePromise;
445 }
446
447 // show() is async, so ensure we don't need to wait for show() to finish
448 var waitPromise = this._showPromise || promise.resolve();
449
450 this._hidePromise = waitPromise.then(() => {
451 this._element.hidden = true;
452
453 Services.prefs.setBoolPref("devtools.toolbar.visible", false);
454
455 this._doc.getElementById("Tools:DevToolbar").setAttribute("checked", "false");
456 this.destroy();
457
458 this._telemetry.toolClosed("developertoolbar");
459 this._notify(NOTIFICATIONS.HIDE);
460
461 this._hidePromise = null;
462 });
463
464 return this._hidePromise;
465 };
466
467 /**
468 * The devtools-unloaded event handler.
469 * @private
470 */
471 DeveloperToolbar.prototype._devtoolsUnloaded = function() {
472 let tabbrowser = this._chromeWindow.getBrowser();
473 Array.prototype.forEach.call(tabbrowser.tabs, this._stopErrorsCount, this);
474 };
475
476 /**
477 * The devtools-loaded event handler.
478 * @private
479 */
480 DeveloperToolbar.prototype._devtoolsLoaded = function() {
481 let tabbrowser = this._chromeWindow.getBrowser();
482 this._initErrorsCount(tabbrowser.selectedTab);
483 };
484
485 /**
486 * Initialize the listeners needed for tracking the number of errors for a given
487 * tab.
488 *
489 * @private
490 * @param nsIDOMNode tab the xul:tab for which you want to track the number of
491 * errors.
492 */
493 DeveloperToolbar.prototype._initErrorsCount = function(tab) {
494 let tabId = tab.linkedPanel;
495 if (tabId in this._errorsCount) {
496 this._updateErrorsCount();
497 return;
498 }
499
500 let window = tab.linkedBrowser.contentWindow;
501 let listener = new ConsoleServiceListener(window, {
502 onConsoleServiceMessage: this._onPageError.bind(this, tabId),
503 });
504 listener.init();
505
506 this._errorListeners[tabId] = listener;
507 this._errorsCount[tabId] = 0;
508 this._warningsCount[tabId] = 0;
509
510 let messages = listener.getCachedMessages();
511 messages.forEach(this._onPageError.bind(this, tabId));
512
513 this._updateErrorsCount();
514 };
515
516 /**
517 * Stop the listeners needed for tracking the number of errors for a given
518 * tab.
519 *
520 * @private
521 * @param nsIDOMNode tab the xul:tab for which you want to stop tracking the
522 * number of errors.
523 */
524 DeveloperToolbar.prototype._stopErrorsCount = function(tab) {
525 let tabId = tab.linkedPanel;
526 if (!(tabId in this._errorsCount) || !(tabId in this._warningsCount)) {
527 this._updateErrorsCount();
528 return;
529 }
530
531 this._errorListeners[tabId].destroy();
532 delete this._errorListeners[tabId];
533 delete this._errorsCount[tabId];
534 delete this._warningsCount[tabId];
535
536 this._updateErrorsCount();
537 };
538
539 /**
540 * Hide the developer toolbar
541 */
542 DeveloperToolbar.prototype.destroy = function() {
543 if (this._input == null) {
544 return; // Already destroyed
545 }
546
547 let tabbrowser = this._chromeWindow.getBrowser();
548 tabbrowser.tabContainer.removeEventListener("TabSelect", this, false);
549 tabbrowser.tabContainer.removeEventListener("TabClose", this, false);
550 tabbrowser.removeEventListener("load", this, true);
551 tabbrowser.removeEventListener("beforeunload", this, true);
552
553 Services.obs.removeObserver(this._devtoolsUnloaded, "devtools-unloaded");
554 Services.obs.removeObserver(this._devtoolsLoaded, "devtools-loaded");
555 Array.prototype.forEach.call(tabbrowser.tabs, this._stopErrorsCount, this);
556
557 this.display.focusManager.removeMonitoredElement(this.outputPanel._frame);
558 this.display.focusManager.removeMonitoredElement(this._element);
559
560 this.display.onVisibilityChange.remove(this.outputPanel._visibilityChanged, this.outputPanel);
561 this.display.onVisibilityChange.remove(this.tooltipPanel._visibilityChanged, this.tooltipPanel);
562 this.display.onOutput.remove(this.outputPanel._outputChanged, this.outputPanel);
563 this.display.destroy();
564 this.outputPanel.destroy();
565 this.tooltipPanel.destroy();
566 delete this._input;
567
568 // We could "delete this.display" etc if we have hard-to-track-down memory
569 // leaks as a belt-and-braces approach, however this prevents our DOM node
570 // hunter from looking in all the nooks and crannies, so it's better if we
571 // can be leak-free without
572 /*
573 delete this.display;
574 delete this.outputPanel;
575 delete this.tooltipPanel;
576 */
577 };
578
579 /**
580 * Utility for sending notifications
581 * @param topic a NOTIFICATION constant
582 */
583 DeveloperToolbar.prototype._notify = function(topic) {
584 let data = { toolbar: this };
585 data.wrappedJSObject = data;
586 Services.obs.notifyObservers(data, topic, null);
587 };
588
589 /**
590 * Update various parts of the UI when the current tab changes
591 */
592 DeveloperToolbar.prototype.handleEvent = function(ev) {
593 if (ev.type == "TabSelect" || ev.type == "load") {
594 if (this.visible) {
595 this.display.reattach({
596 contentDocument: this._chromeWindow.getBrowser().contentDocument
597 });
598
599 if (ev.type == "TabSelect") {
600 this._initErrorsCount(ev.target);
601 }
602 }
603 }
604 else if (ev.type == "TabClose") {
605 this._stopErrorsCount(ev.target);
606 }
607 else if (ev.type == "beforeunload") {
608 this._onPageBeforeUnload(ev);
609 }
610 };
611
612 /**
613 * Count a page error received for the currently selected tab. This
614 * method counts the JavaScript exceptions received and CSS errors/warnings.
615 *
616 * @private
617 * @param string tabId the ID of the tab from where the page error comes.
618 * @param object pageError the page error object received from the
619 * PageErrorListener.
620 */
621 DeveloperToolbar.prototype._onPageError = function(tabId, pageError) {
622 if (pageError.category == "CSS Parser" ||
623 pageError.category == "CSS Loader") {
624 return;
625 }
626 if ((pageError.flags & pageError.warningFlag) ||
627 (pageError.flags & pageError.strictFlag)) {
628 this._warningsCount[tabId]++;
629 } else {
630 this._errorsCount[tabId]++;
631 }
632 this._updateErrorsCount(tabId);
633 };
634
635 /**
636 * The |beforeunload| event handler. This function resets the errors count when
637 * a different page starts loading.
638 *
639 * @private
640 * @param nsIDOMEvent ev the beforeunload DOM event.
641 */
642 DeveloperToolbar.prototype._onPageBeforeUnload = function(ev) {
643 let window = ev.target.defaultView;
644 if (window.top !== window) {
645 return;
646 }
647
648 let tabs = this._chromeWindow.getBrowser().tabs;
649 Array.prototype.some.call(tabs, function(tab) {
650 if (tab.linkedBrowser.contentWindow === window) {
651 let tabId = tab.linkedPanel;
652 if (tabId in this._errorsCount || tabId in this._warningsCount) {
653 this._errorsCount[tabId] = 0;
654 this._warningsCount[tabId] = 0;
655 this._updateErrorsCount(tabId);
656 }
657 return true;
658 }
659 return false;
660 }, this);
661 };
662
663 /**
664 * Update the page errors count displayed in the Web Console button for the
665 * currently selected tab.
666 *
667 * @private
668 * @param string [changedTabId] Optional. The tab ID that had its page errors
669 * count changed. If this is provided and it doesn't match the currently
670 * selected tab, then the button is not updated.
671 */
672 DeveloperToolbar.prototype._updateErrorsCount = function(changedTabId) {
673 let tabId = this._chromeWindow.getBrowser().selectedTab.linkedPanel;
674 if (changedTabId && tabId != changedTabId) {
675 return;
676 }
677
678 let errors = this._errorsCount[tabId];
679 let warnings = this._warningsCount[tabId];
680 let btn = this._errorCounterButton;
681 if (errors) {
682 let errorsText = toolboxStrings
683 .GetStringFromName("toolboxToggleButton.errors");
684 errorsText = PluralForm.get(errors, errorsText).replace("#1", errors);
685
686 let warningsText = toolboxStrings
687 .GetStringFromName("toolboxToggleButton.warnings");
688 warningsText = PluralForm.get(warnings, warningsText).replace("#1", warnings);
689
690 let tooltiptext = toolboxStrings
691 .formatStringFromName("toolboxToggleButton.tooltip",
692 [errorsText, warningsText], 2);
693
694 btn.setAttribute("error-count", errors);
695 btn.setAttribute("tooltiptext", tooltiptext);
696 } else {
697 btn.removeAttribute("error-count");
698 btn.setAttribute("tooltiptext", btn._defaultTooltipText);
699 }
700
701 this.emit("errors-counter-updated");
702 };
703
704 /**
705 * Reset the errors counter for the given tab.
706 *
707 * @param nsIDOMElement tab The xul:tab for which you want to reset the page
708 * errors counters.
709 */
710 DeveloperToolbar.prototype.resetErrorsCount = function(tab) {
711 let tabId = tab.linkedPanel;
712 if (tabId in this._errorsCount || tabId in this._warningsCount) {
713 this._errorsCount[tabId] = 0;
714 this._warningsCount[tabId] = 0;
715 this._updateErrorsCount(tabId);
716 }
717 };
718
719 /**
720 * Creating a OutputPanel is asynchronous
721 */
722 function OutputPanel() {
723 throw new Error('Use OutputPanel.create()');
724 }
725
726 /**
727 * Panel to handle command line output.
728 *
729 * There is a tooltip bug on Windows and OSX that prevents tooltips from being
730 * positioned properly (bug 786975). There is a Gnome panel bug on Linux that
731 * causes ugly focus issues (https://bugzilla.gnome.org/show_bug.cgi?id=621848).
732 * We now use a tooltip on Linux and a panel on OSX & Windows.
733 *
734 * If a panel has no content and no height it is not shown when openPopup is
735 * called on Windows and OSX (bug 692348) ... this prevents the panel from
736 * appearing the first time it is shown. Setting the panel's height to 1px
737 * before calling openPopup works around this issue as we resize it ourselves
738 * anyway.
739 *
740 * @param devtoolbar The parent DeveloperToolbar object
741 */
742 OutputPanel.create = function(devtoolbar) {
743 var outputPanel = Object.create(OutputPanel.prototype);
744 return outputPanel._init(devtoolbar);
745 };
746
747 /**
748 * @private See OutputPanel.create
749 */
750 OutputPanel.prototype._init = function(devtoolbar) {
751 this._devtoolbar = devtoolbar;
752 this._input = this._devtoolbar._input;
753 this._toolbar = this._devtoolbar._doc.getElementById("developer-toolbar");
754
755 /*
756 <tooltip|panel id="gcli-output"
757 noautofocus="true"
758 noautohide="true"
759 class="gcli-panel">
760 <html:iframe xmlns:html="http://www.w3.org/1999/xhtml"
761 id="gcli-output-frame"
762 src="chrome://browser/content/devtools/commandlineoutput.xhtml"
763 sandbox="allow-same-origin"/>
764 </tooltip|panel>
765 */
766
767 // TODO: Switch back from tooltip to panel when metacity focus issue is fixed:
768 // https://bugzilla.mozilla.org/show_bug.cgi?id=780102
769 this._panel = this._devtoolbar._doc.createElement(isLinux ? "tooltip" : "panel");
770
771 this._panel.id = "gcli-output";
772 this._panel.classList.add("gcli-panel");
773
774 if (isLinux) {
775 this.canHide = false;
776 this._onpopuphiding = this._onpopuphiding.bind(this);
777 this._panel.addEventListener("popuphiding", this._onpopuphiding, true);
778 } else {
779 this._panel.setAttribute("noautofocus", "true");
780 this._panel.setAttribute("noautohide", "true");
781
782 // Bug 692348: On Windows and OSX if a panel has no content and no height
783 // openPopup fails to display it. Setting the height to 1px alows the panel
784 // to be displayed before has content or a real height i.e. the first time
785 // it is displayed.
786 this._panel.setAttribute("height", "1px");
787 }
788
789 this._toolbar.parentElement.insertBefore(this._panel, this._toolbar);
790
791 this._frame = this._devtoolbar._doc.createElementNS(NS_XHTML, "iframe");
792 this._frame.id = "gcli-output-frame";
793 this._frame.setAttribute("src", "chrome://browser/content/devtools/commandlineoutput.xhtml");
794 this._frame.setAttribute("sandbox", "allow-same-origin");
795 this._panel.appendChild(this._frame);
796
797 this.displayedOutput = undefined;
798
799 this._update = this._update.bind(this);
800
801 // Wire up the element from the iframe, and resolve the promise
802 let deferred = promise.defer();
803 let onload = () => {
804 this._frame.removeEventListener("load", onload, true);
805
806 this.document = this._frame.contentDocument;
807
808 this._div = this.document.getElementById("gcli-output-root");
809 this._div.classList.add('gcli-row-out');
810 this._div.setAttribute('aria-live', 'assertive');
811
812 let styles = this._toolbar.ownerDocument.defaultView
813 .getComputedStyle(this._toolbar);
814 this._div.setAttribute("dir", styles.direction);
815
816 deferred.resolve(this);
817 };
818 this._frame.addEventListener("load", onload, true);
819
820 return deferred.promise;
821 }
822
823 /**
824 * Prevent the popup from hiding if it is not permitted via this.canHide.
825 */
826 OutputPanel.prototype._onpopuphiding = function(ev) {
827 // TODO: When we switch back from tooltip to panel we can remove this hack:
828 // https://bugzilla.mozilla.org/show_bug.cgi?id=780102
829 if (isLinux && !this.canHide) {
830 ev.preventDefault();
831 }
832 };
833
834 /**
835 * Display the OutputPanel.
836 */
837 OutputPanel.prototype.show = function() {
838 if (isLinux) {
839 this.canHide = false;
840 }
841
842 // We need to reset the iframe size in order for future size calculations to
843 // be correct
844 this._frame.style.minHeight = this._frame.style.maxHeight = 0;
845 this._frame.style.minWidth = 0;
846
847 this._panel.openPopup(this._input, "before_start", 0, 0, false, false, null);
848 this._resize();
849
850 this._input.focus();
851 };
852
853 /**
854 * Internal helper to set the height of the output panel to fit the available
855 * content;
856 */
857 OutputPanel.prototype._resize = function() {
858 if (this._panel == null || this.document == null || !this._panel.state == "closed") {
859 return
860 }
861
862 // Set max panel width to match any content with a max of the width of the
863 // browser window.
864 let maxWidth = this._panel.ownerDocument.documentElement.clientWidth;
865
866 // Adjust max width according to OS.
867 // We'd like to put this in CSS but we can't:
868 // body { width: calc(min(-5px, max-content)); }
869 // #_panel { max-width: -5px; }
870 switch(OS) {
871 case "Linux":
872 maxWidth -= 5;
873 break;
874 case "Darwin":
875 maxWidth -= 25;
876 break;
877 case "WINNT":
878 maxWidth -= 5;
879 break;
880 }
881
882 this.document.body.style.width = "-moz-max-content";
883 let style = this._frame.contentWindow.getComputedStyle(this.document.body);
884 let frameWidth = parseInt(style.width, 10);
885 let width = Math.min(maxWidth, frameWidth);
886 this.document.body.style.width = width + "px";
887
888 // Set the width of the iframe.
889 this._frame.style.minWidth = width + "px";
890 this._panel.style.maxWidth = maxWidth + "px";
891
892 // browserAdjustment is used to correct the panel height according to the
893 // browsers borders etc.
894 const browserAdjustment = 15;
895
896 // Set max panel height to match any content with a max of the height of the
897 // browser window.
898 let maxHeight =
899 this._panel.ownerDocument.documentElement.clientHeight - browserAdjustment;
900 let height = Math.min(maxHeight, this.document.documentElement.scrollHeight);
901
902 // Set the height of the iframe. Setting iframe.height does not work.
903 this._frame.style.minHeight = this._frame.style.maxHeight = height + "px";
904
905 // Set the height and width of the panel to match the iframe.
906 this._panel.sizeTo(width, height);
907
908 // Move the panel to the correct position in the case that it has been
909 // positioned incorrectly.
910 let screenX = this._input.boxObject.screenX;
911 let screenY = this._toolbar.boxObject.screenY;
912 this._panel.moveTo(screenX, screenY - height);
913 };
914
915 /**
916 * Called by GCLI when a command is executed.
917 */
918 OutputPanel.prototype._outputChanged = function(ev) {
919 if (ev.output.hidden) {
920 return;
921 }
922
923 this.remove();
924
925 this.displayedOutput = ev.output;
926
927 if (this.displayedOutput.completed) {
928 this._update();
929 }
930 else {
931 this.displayedOutput.promise.then(this._update, this._update)
932 .then(null, console.error);
933 }
934 };
935
936 /**
937 * Called when displayed Output says it's changed or from outputChanged, which
938 * happens when there is a new displayed Output.
939 */
940 OutputPanel.prototype._update = function() {
941 // destroy has been called, bail out
942 if (this._div == null) {
943 return;
944 }
945
946 // Empty this._div
947 while (this._div.hasChildNodes()) {
948 this._div.removeChild(this._div.firstChild);
949 }
950
951 if (this.displayedOutput.data != null) {
952 let context = this._devtoolbar.display.requisition.conversionContext;
953 this.displayedOutput.convert('dom', context).then((node) => {
954 while (this._div.hasChildNodes()) {
955 this._div.removeChild(this._div.firstChild);
956 }
957
958 var links = node.ownerDocument.querySelectorAll('*[href]');
959 for (var i = 0; i < links.length; i++) {
960 links[i].setAttribute('target', '_blank');
961 }
962
963 this._div.appendChild(node);
964 this.show();
965 });
966 }
967 };
968
969 /**
970 * Detach listeners from the currently displayed Output.
971 */
972 OutputPanel.prototype.remove = function() {
973 if (isLinux) {
974 this.canHide = true;
975 }
976
977 if (this._panel && this._panel.hidePopup) {
978 this._panel.hidePopup();
979 }
980
981 if (this.displayedOutput) {
982 delete this.displayedOutput;
983 }
984 };
985
986 /**
987 * Detach listeners from the currently displayed Output.
988 */
989 OutputPanel.prototype.destroy = function() {
990 this.remove();
991
992 this._panel.removeEventListener("popuphiding", this._onpopuphiding, true);
993
994 this._panel.removeChild(this._frame);
995 this._toolbar.parentElement.removeChild(this._panel);
996
997 delete this._devtoolbar;
998 delete this._input;
999 delete this._toolbar;
1000 delete this._onpopuphiding;
1001 delete this._panel;
1002 delete this._frame;
1003 delete this._content;
1004 delete this._div;
1005 delete this.document;
1006 };
1007
1008 /**
1009 * Called by GCLI to indicate that we should show or hide one either the
1010 * tooltip panel or the output panel.
1011 */
1012 OutputPanel.prototype._visibilityChanged = function(ev) {
1013 if (ev.outputVisible === true) {
1014 // this.show is called by _outputChanged
1015 } else {
1016 if (isLinux) {
1017 this.canHide = true;
1018 }
1019 this._panel.hidePopup();
1020 }
1021 };
1022
1023 /**
1024 * Creating a TooltipPanel is asynchronous
1025 */
1026 function TooltipPanel() {
1027 throw new Error('Use TooltipPanel.create()');
1028 }
1029
1030 /**
1031 * Panel to handle tooltips.
1032 *
1033 * There is a tooltip bug on Windows and OSX that prevents tooltips from being
1034 * positioned properly (bug 786975). There is a Gnome panel bug on Linux that
1035 * causes ugly focus issues (https://bugzilla.gnome.org/show_bug.cgi?id=621848).
1036 * We now use a tooltip on Linux and a panel on OSX & Windows.
1037 *
1038 * If a panel has no content and no height it is not shown when openPopup is
1039 * called on Windows and OSX (bug 692348) ... this prevents the panel from
1040 * appearing the first time it is shown. Setting the panel's height to 1px
1041 * before calling openPopup works around this issue as we resize it ourselves
1042 * anyway.
1043 *
1044 * @param devtoolbar The parent DeveloperToolbar object
1045 */
1046 TooltipPanel.create = function(devtoolbar) {
1047 var tooltipPanel = Object.create(TooltipPanel.prototype);
1048 return tooltipPanel._init(devtoolbar);
1049 };
1050
1051 /**
1052 * @private See TooltipPanel.create
1053 */
1054 TooltipPanel.prototype._init = function(devtoolbar) {
1055 let deferred = promise.defer();
1056
1057 let chromeDocument = devtoolbar._doc;
1058 this._input = devtoolbar._doc.querySelector(".gclitoolbar-input-node");
1059 this._toolbar = devtoolbar._doc.querySelector("#developer-toolbar");
1060 this._dimensions = { start: 0, end: 0 };
1061
1062 /*
1063 <tooltip|panel id="gcli-tooltip"
1064 type="arrow"
1065 noautofocus="true"
1066 noautohide="true"
1067 class="gcli-panel">
1068 <html:iframe xmlns:html="http://www.w3.org/1999/xhtml"
1069 id="gcli-tooltip-frame"
1070 src="chrome://browser/content/devtools/commandlinetooltip.xhtml"
1071 flex="1"
1072 sandbox="allow-same-origin"/>
1073 </tooltip|panel>
1074 */
1075
1076 // TODO: Switch back from tooltip to panel when metacity focus issue is fixed:
1077 // https://bugzilla.mozilla.org/show_bug.cgi?id=780102
1078 this._panel = devtoolbar._doc.createElement(isLinux ? "tooltip" : "panel");
1079
1080 this._panel.id = "gcli-tooltip";
1081 this._panel.classList.add("gcli-panel");
1082
1083 if (isLinux) {
1084 this.canHide = false;
1085 this._onpopuphiding = this._onpopuphiding.bind(this);
1086 this._panel.addEventListener("popuphiding", this._onpopuphiding, true);
1087 } else {
1088 this._panel.setAttribute("noautofocus", "true");
1089 this._panel.setAttribute("noautohide", "true");
1090
1091 // Bug 692348: On Windows and OSX if a panel has no content and no height
1092 // openPopup fails to display it. Setting the height to 1px alows the panel
1093 // to be displayed before has content or a real height i.e. the first time
1094 // it is displayed.
1095 this._panel.setAttribute("height", "1px");
1096 }
1097
1098 this._toolbar.parentElement.insertBefore(this._panel, this._toolbar);
1099
1100 this._frame = devtoolbar._doc.createElementNS(NS_XHTML, "iframe");
1101 this._frame.id = "gcli-tooltip-frame";
1102 this._frame.setAttribute("src", "chrome://browser/content/devtools/commandlinetooltip.xhtml");
1103 this._frame.setAttribute("flex", "1");
1104 this._frame.setAttribute("sandbox", "allow-same-origin");
1105 this._panel.appendChild(this._frame);
1106
1107 /**
1108 * Wire up the element from the iframe, and resolve the promise.
1109 */
1110 let onload = () => {
1111 this._frame.removeEventListener("load", onload, true);
1112
1113 this.document = this._frame.contentDocument;
1114 this.hintElement = this.document.getElementById("gcli-tooltip-root");
1115 this._connector = this.document.getElementById("gcli-tooltip-connector");
1116
1117 let styles = this._toolbar.ownerDocument.defaultView
1118 .getComputedStyle(this._toolbar);
1119 this.hintElement.setAttribute("dir", styles.direction);
1120
1121 deferred.resolve(this);
1122 };
1123 this._frame.addEventListener("load", onload, true);
1124
1125 return deferred.promise;
1126 }
1127
1128 /**
1129 * Prevent the popup from hiding if it is not permitted via this.canHide.
1130 */
1131 TooltipPanel.prototype._onpopuphiding = function(ev) {
1132 // TODO: When we switch back from tooltip to panel we can remove this hack:
1133 // https://bugzilla.mozilla.org/show_bug.cgi?id=780102
1134 if (isLinux && !this.canHide) {
1135 ev.preventDefault();
1136 }
1137 };
1138
1139 /**
1140 * Display the TooltipPanel.
1141 */
1142 TooltipPanel.prototype.show = function(dimensions) {
1143 if (!dimensions) {
1144 dimensions = { start: 0, end: 0 };
1145 }
1146 this._dimensions = dimensions;
1147
1148 // This is nasty, but displaying the panel causes it to re-flow, which can
1149 // change the size it should be, so we need to resize the iframe after the
1150 // panel has displayed
1151 this._panel.ownerDocument.defaultView.setTimeout(() => {
1152 this._resize();
1153 }, 0);
1154
1155 if (isLinux) {
1156 this.canHide = false;
1157 }
1158
1159 this._resize();
1160 this._panel.openPopup(this._input, "before_start", dimensions.start * 10, 0,
1161 false, false, null);
1162 this._input.focus();
1163 };
1164
1165 /**
1166 * One option is to spend lots of time taking an average width of characters
1167 * in the current font, dynamically, and weighting for the frequency of use of
1168 * various characters, or even to render the given string off screen, and then
1169 * measure the width.
1170 * Or we could do this...
1171 */
1172 const AVE_CHAR_WIDTH = 4.5;
1173
1174 /**
1175 * Display the TooltipPanel.
1176 */
1177 TooltipPanel.prototype._resize = function() {
1178 if (this._panel == null || this.document == null || !this._panel.state == "closed") {
1179 return
1180 }
1181
1182 let offset = 10 + Math.floor(this._dimensions.start * AVE_CHAR_WIDTH);
1183 this._panel.style.marginLeft = offset + "px";
1184
1185 /*
1186 // Bug 744906: UX review - Not sure if we want this code to fatten connector
1187 // with param width
1188 let width = Math.floor(this._dimensions.end * AVE_CHAR_WIDTH);
1189 width = Math.min(width, 100);
1190 width = Math.max(width, 10);
1191 this._connector.style.width = width + "px";
1192 */
1193
1194 this._frame.height = this.document.body.scrollHeight;
1195 };
1196
1197 /**
1198 * Hide the TooltipPanel.
1199 */
1200 TooltipPanel.prototype.remove = function() {
1201 if (isLinux) {
1202 this.canHide = true;
1203 }
1204 if (this._panel && this._panel.hidePopup) {
1205 this._panel.hidePopup();
1206 }
1207 };
1208
1209 /**
1210 * Hide the TooltipPanel.
1211 */
1212 TooltipPanel.prototype.destroy = function() {
1213 this.remove();
1214
1215 this._panel.removeEventListener("popuphiding", this._onpopuphiding, true);
1216
1217 this._panel.removeChild(this._frame);
1218 this._toolbar.parentElement.removeChild(this._panel);
1219
1220 delete this._connector;
1221 delete this._dimensions;
1222 delete this._input;
1223 delete this._onpopuphiding;
1224 delete this._panel;
1225 delete this._frame;
1226 delete this._toolbar;
1227 delete this._content;
1228 delete this.document;
1229 delete this.hintElement;
1230 };
1231
1232 /**
1233 * Called by GCLI to indicate that we should show or hide one either the
1234 * tooltip panel or the output panel.
1235 */
1236 TooltipPanel.prototype._visibilityChanged = function(ev) {
1237 if (ev.tooltipVisible === true) {
1238 this.show(ev.dimensions);
1239 } else {
1240 if (isLinux) {
1241 this.canHide = true;
1242 }
1243 this._panel.hidePopup();
1244 }
1245 };

mercurial