michael@0: /* vim:set ts=2 sw=2 sts=2 et: */ michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: "use strict"; michael@0: michael@0: this.EXPORTED_SYMBOLS = ["StyleEditorUI"]; michael@0: michael@0: const Cc = Components.classes; michael@0: const Ci = Components.interfaces; michael@0: const Cu = Components.utils; michael@0: michael@0: Cu.import("resource://gre/modules/XPCOMUtils.jsm"); michael@0: Cu.import("resource://gre/modules/Services.jsm"); michael@0: Cu.import("resource://gre/modules/NetUtil.jsm"); michael@0: Cu.import("resource://gre/modules/osfile.jsm"); michael@0: Cu.import("resource://gre/modules/Task.jsm"); michael@0: Cu.import("resource://gre/modules/devtools/event-emitter.js"); michael@0: Cu.import("resource:///modules/devtools/gDevTools.jsm"); michael@0: Cu.import("resource:///modules/devtools/StyleEditorUtil.jsm"); michael@0: Cu.import("resource:///modules/devtools/SplitView.jsm"); michael@0: Cu.import("resource:///modules/devtools/StyleSheetEditor.jsm"); michael@0: const { Promise: promise } = Cu.import("resource://gre/modules/Promise.jsm", {}); michael@0: michael@0: XPCOMUtils.defineLazyModuleGetter(this, "PluralForm", michael@0: "resource://gre/modules/PluralForm.jsm"); michael@0: michael@0: const require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require; michael@0: const { PrefObserver, PREF_ORIG_SOURCES } = require("devtools/styleeditor/utils"); michael@0: michael@0: const LOAD_ERROR = "error-load"; michael@0: const STYLE_EDITOR_TEMPLATE = "stylesheet"; michael@0: michael@0: /** michael@0: * StyleEditorUI is controls and builds the UI of the Style Editor, including michael@0: * maintaining a list of editors for each stylesheet on a debuggee. michael@0: * michael@0: * Emits events: michael@0: * 'editor-added': A new editor was added to the UI michael@0: * 'editor-selected': An editor was selected michael@0: * 'error': An error occured michael@0: * michael@0: * @param {StyleEditorFront} debuggee michael@0: * Client-side front for interacting with the page's stylesheets michael@0: * @param {Target} target michael@0: * Interface for the page we're debugging michael@0: * @param {Document} panelDoc michael@0: * Document of the toolbox panel to populate UI in. michael@0: */ michael@0: function StyleEditorUI(debuggee, target, panelDoc) { michael@0: EventEmitter.decorate(this); michael@0: michael@0: this._debuggee = debuggee; michael@0: this._target = target; michael@0: this._panelDoc = panelDoc; michael@0: this._window = this._panelDoc.defaultView; michael@0: this._root = this._panelDoc.getElementById("style-editor-chrome"); michael@0: michael@0: this.editors = []; michael@0: this.selectedEditor = null; michael@0: this.savedLocations = {}; michael@0: michael@0: this._updateSourcesLabel = this._updateSourcesLabel.bind(this); michael@0: this._onStyleSheetCreated = this._onStyleSheetCreated.bind(this); michael@0: this._onNewDocument = this._onNewDocument.bind(this); michael@0: this._clear = this._clear.bind(this); michael@0: this._onError = this._onError.bind(this); michael@0: michael@0: this._prefObserver = new PrefObserver("devtools.styleeditor."); michael@0: this._prefObserver.on(PREF_ORIG_SOURCES, this._onNewDocument); michael@0: } michael@0: michael@0: StyleEditorUI.prototype = { michael@0: /** michael@0: * Get whether any of the editors have unsaved changes. michael@0: * michael@0: * @return boolean michael@0: */ michael@0: get isDirty() { michael@0: if (this._markedDirty === true) { michael@0: return true; michael@0: } michael@0: return this.editors.some((editor) => { michael@0: return editor.sourceEditor && !editor.sourceEditor.isClean(); michael@0: }); michael@0: }, michael@0: michael@0: /* michael@0: * Mark the style editor as having or not having unsaved changes. michael@0: */ michael@0: set isDirty(value) { michael@0: this._markedDirty = value; michael@0: }, michael@0: michael@0: /* michael@0: * Index of selected stylesheet in document.styleSheets michael@0: */ michael@0: get selectedStyleSheetIndex() { michael@0: return this.selectedEditor ? michael@0: this.selectedEditor.styleSheet.styleSheetIndex : -1; michael@0: }, michael@0: michael@0: /** michael@0: * Initiates the style editor ui creation and the inspector front to get michael@0: * reference to the walker. michael@0: */ michael@0: initialize: function() { michael@0: let toolbox = gDevTools.getToolbox(this._target); michael@0: return toolbox.initInspector().then(() => { michael@0: this._walker = toolbox.walker; michael@0: }).then(() => { michael@0: this.createUI(); michael@0: this._debuggee.getStyleSheets().then((styleSheets) => { michael@0: this._resetStyleSheetList(styleSheets); michael@0: michael@0: this._target.on("will-navigate", this._clear); michael@0: this._target.on("navigate", this._onNewDocument); michael@0: }); michael@0: }); michael@0: }, michael@0: michael@0: /** michael@0: * Build the initial UI and wire buttons with event handlers. michael@0: */ michael@0: createUI: function() { michael@0: let viewRoot = this._root.parentNode.querySelector(".splitview-root"); michael@0: michael@0: this._view = new SplitView(viewRoot); michael@0: michael@0: wire(this._view.rootElement, ".style-editor-newButton", function onNew() { michael@0: this._debuggee.addStyleSheet(null).then(this._onStyleSheetCreated); michael@0: }.bind(this)); michael@0: michael@0: wire(this._view.rootElement, ".style-editor-importButton", function onImport() { michael@0: this._importFromFile(this._mockImportFile || null, this._window); michael@0: }.bind(this)); michael@0: michael@0: this._contextMenu = this._panelDoc.getElementById("sidebar-context"); michael@0: this._contextMenu.addEventListener("popupshowing", michael@0: this._updateSourcesLabel); michael@0: michael@0: this._sourcesItem = this._panelDoc.getElementById("context-origsources"); michael@0: this._sourcesItem.addEventListener("command", michael@0: this._toggleOrigSources); michael@0: }, michael@0: michael@0: /** michael@0: * Update text of context menu option to reflect whether we're showing michael@0: * original sources (e.g. Sass files) or not. michael@0: */ michael@0: _updateSourcesLabel: function() { michael@0: let string = "showOriginalSources"; michael@0: if (Services.prefs.getBoolPref(PREF_ORIG_SOURCES)) { michael@0: string = "showCSSSources"; michael@0: } michael@0: this._sourcesItem.setAttribute("label", _(string + ".label")); michael@0: this._sourcesItem.setAttribute("accesskey", _(string + ".accesskey")); michael@0: }, michael@0: michael@0: /** michael@0: * Refresh editors to reflect the stylesheets in the document. michael@0: * michael@0: * @param {string} event michael@0: * Event name michael@0: * @param {StyleSheet} styleSheet michael@0: * StyleSheet object for new sheet michael@0: */ michael@0: _onNewDocument: function() { michael@0: this._debuggee.getStyleSheets().then((styleSheets) => { michael@0: this._resetStyleSheetList(styleSheets); michael@0: }) michael@0: }, michael@0: michael@0: /** michael@0: * Add editors for all the given stylesheets to the UI. michael@0: * michael@0: * @param {array} styleSheets michael@0: * Array of StyleSheetFront michael@0: */ michael@0: _resetStyleSheetList: function(styleSheets) { michael@0: this._clear(); michael@0: michael@0: for (let sheet of styleSheets) { michael@0: this._addStyleSheet(sheet); michael@0: } michael@0: michael@0: this._root.classList.remove("loading"); michael@0: michael@0: this.emit("stylesheets-reset"); michael@0: }, michael@0: michael@0: /** michael@0: * Remove all editors and add loading indicator. michael@0: */ michael@0: _clear: function() { michael@0: // remember selected sheet and line number for next load michael@0: if (this.selectedEditor && this.selectedEditor.sourceEditor) { michael@0: let href = this.selectedEditor.styleSheet.href; michael@0: let {line, ch} = this.selectedEditor.sourceEditor.getCursor(); michael@0: michael@0: this._styleSheetToSelect = { michael@0: href: href, michael@0: line: line, michael@0: col: ch michael@0: }; michael@0: } michael@0: michael@0: // remember saved file locations michael@0: for (let editor of this.editors) { michael@0: if (editor.savedFile) { michael@0: let identifier = this.getStyleSheetIdentifier(editor.styleSheet); michael@0: this.savedLocations[identifier] = editor.savedFile; michael@0: } michael@0: } michael@0: michael@0: this._clearStyleSheetEditors(); michael@0: this._view.removeAll(); michael@0: michael@0: this.selectedEditor = null; michael@0: michael@0: this._root.classList.add("loading"); michael@0: }, michael@0: michael@0: /** michael@0: * Add an editor for this stylesheet. Add editors for its original sources michael@0: * instead (e.g. Sass sources), if applicable. michael@0: * michael@0: * @param {StyleSheetFront} styleSheet michael@0: * Style sheet to add to style editor michael@0: */ michael@0: _addStyleSheet: function(styleSheet) { michael@0: let editor = this._addStyleSheetEditor(styleSheet); michael@0: michael@0: if (!Services.prefs.getBoolPref(PREF_ORIG_SOURCES)) { michael@0: return; michael@0: } michael@0: michael@0: styleSheet.getOriginalSources().then((sources) => { michael@0: if (sources && sources.length) { michael@0: this._removeStyleSheetEditor(editor); michael@0: sources.forEach((source) => { michael@0: // set so the first sheet will be selected, even if it's a source michael@0: source.styleSheetIndex = styleSheet.styleSheetIndex; michael@0: source.relatedStyleSheet = styleSheet; michael@0: michael@0: this._addStyleSheetEditor(source); michael@0: }); michael@0: } michael@0: }); michael@0: }, michael@0: michael@0: /** michael@0: * Add a new editor to the UI for a source. michael@0: * michael@0: * @param {StyleSheet} styleSheet michael@0: * Object representing stylesheet michael@0: * @param {nsIfile} file michael@0: * Optional file object that sheet was imported from michael@0: * @param {Boolean} isNew michael@0: * Optional if stylesheet is a new sheet created by user michael@0: */ michael@0: _addStyleSheetEditor: function(styleSheet, file, isNew) { michael@0: // recall location of saved file for this sheet after page reload michael@0: let identifier = this.getStyleSheetIdentifier(styleSheet); michael@0: let savedFile = this.savedLocations[identifier]; michael@0: if (savedFile && !file) { michael@0: file = savedFile; michael@0: } michael@0: michael@0: let editor = michael@0: new StyleSheetEditor(styleSheet, this._window, file, isNew, this._walker); michael@0: michael@0: editor.on("property-change", this._summaryChange.bind(this, editor)); michael@0: editor.on("linked-css-file", this._summaryChange.bind(this, editor)); michael@0: editor.on("linked-css-file-error", this._summaryChange.bind(this, editor)); michael@0: editor.on("error", this._onError); michael@0: michael@0: this.editors.push(editor); michael@0: michael@0: editor.fetchSource(this._sourceLoaded.bind(this, editor)); michael@0: return editor; michael@0: }, michael@0: michael@0: /** michael@0: * Import a style sheet from file and asynchronously create a michael@0: * new stylesheet on the debuggee for it. michael@0: * michael@0: * @param {mixed} file michael@0: * Optional nsIFile or filename string. michael@0: * If not set a file picker will be shown. michael@0: * @param {nsIWindow} parentWindow michael@0: * Optional parent window for the file picker. michael@0: */ michael@0: _importFromFile: function(file, parentWindow) { michael@0: let onFileSelected = function(file) { michael@0: if (!file) { michael@0: // nothing selected michael@0: return; michael@0: } michael@0: NetUtil.asyncFetch(file, (stream, status) => { michael@0: if (!Components.isSuccessCode(status)) { michael@0: this.emit("error", LOAD_ERROR); michael@0: return; michael@0: } michael@0: let source = NetUtil.readInputStreamToString(stream, stream.available()); michael@0: stream.close(); michael@0: michael@0: this._debuggee.addStyleSheet(source).then((styleSheet) => { michael@0: this._onStyleSheetCreated(styleSheet, file); michael@0: }); michael@0: }); michael@0: michael@0: }.bind(this); michael@0: michael@0: showFilePicker(file, false, parentWindow, onFileSelected); michael@0: }, michael@0: michael@0: michael@0: /** michael@0: * When a new or imported stylesheet has been added to the document. michael@0: * Add an editor for it. michael@0: */ michael@0: _onStyleSheetCreated: function(styleSheet, file) { michael@0: this._addStyleSheetEditor(styleSheet, file, true); michael@0: }, michael@0: michael@0: /** michael@0: * Forward any error from a stylesheet. michael@0: * michael@0: * @param {string} event michael@0: * Event name michael@0: * @param {string} errorCode michael@0: * Code represeting type of error michael@0: * @param {string} message michael@0: * The full error message michael@0: */ michael@0: _onError: function(event, errorCode, message) { michael@0: this.emit("error", errorCode, message); michael@0: }, michael@0: michael@0: /** michael@0: * Toggle the original sources pref. michael@0: */ michael@0: _toggleOrigSources: function() { michael@0: let isEnabled = Services.prefs.getBoolPref(PREF_ORIG_SOURCES); michael@0: Services.prefs.setBoolPref(PREF_ORIG_SOURCES, !isEnabled); michael@0: }, michael@0: michael@0: /** michael@0: * Remove a particular stylesheet editor from the UI michael@0: * michael@0: * @param {StyleSheetEditor} editor michael@0: * The editor to remove. michael@0: */ michael@0: _removeStyleSheetEditor: function(editor) { michael@0: if (editor.summary) { michael@0: this._view.removeItem(editor.summary); michael@0: } michael@0: else { michael@0: let self = this; michael@0: this.on("editor-added", function onAdd(event, added) { michael@0: if (editor == added) { michael@0: self.off("editor-added", onAdd); michael@0: self._view.removeItem(editor.summary); michael@0: } michael@0: }) michael@0: } michael@0: michael@0: editor.destroy(); michael@0: this.editors.splice(this.editors.indexOf(editor), 1); michael@0: }, michael@0: michael@0: /** michael@0: * Clear all the editors from the UI. michael@0: */ michael@0: _clearStyleSheetEditors: function() { michael@0: for (let editor of this.editors) { michael@0: editor.destroy(); michael@0: } michael@0: this.editors = []; michael@0: }, michael@0: michael@0: /** michael@0: * Called when a StyleSheetEditor's source has been fetched. Create a michael@0: * summary UI for the editor. michael@0: * michael@0: * @param {StyleSheetEditor} editor michael@0: * Editor to create UI for. michael@0: */ michael@0: _sourceLoaded: function(editor) { michael@0: // add new sidebar item and editor to the UI michael@0: this._view.appendTemplatedItem(STYLE_EDITOR_TEMPLATE, { michael@0: data: { michael@0: editor: editor michael@0: }, michael@0: disableAnimations: this._alwaysDisableAnimations, michael@0: ordinal: editor.styleSheet.styleSheetIndex, michael@0: onCreate: function(summary, details, data) { michael@0: let editor = data.editor; michael@0: editor.summary = summary; michael@0: michael@0: wire(summary, ".stylesheet-enabled", function onToggleDisabled(event) { michael@0: event.stopPropagation(); michael@0: event.target.blur(); michael@0: michael@0: editor.toggleDisabled(); michael@0: }); michael@0: michael@0: wire(summary, ".stylesheet-name", { michael@0: events: { michael@0: "keypress": function onStylesheetNameActivate(aEvent) { michael@0: if (aEvent.keyCode == aEvent.DOM_VK_RETURN) { michael@0: this._view.activeSummary = summary; michael@0: } michael@0: }.bind(this) michael@0: } michael@0: }); michael@0: michael@0: wire(summary, ".stylesheet-saveButton", function onSaveButton(event) { michael@0: event.stopPropagation(); michael@0: event.target.blur(); michael@0: michael@0: editor.saveToFile(editor.savedFile); michael@0: }); michael@0: michael@0: this._updateSummaryForEditor(editor, summary); michael@0: michael@0: summary.addEventListener("focus", function onSummaryFocus(event) { michael@0: if (event.target == summary) { michael@0: // autofocus the stylesheet name michael@0: summary.querySelector(".stylesheet-name").focus(); michael@0: } michael@0: }, false); michael@0: michael@0: Task.spawn(function* () { michael@0: // autofocus if it's a new user-created stylesheet michael@0: if (editor.isNew) { michael@0: yield this._selectEditor(editor); michael@0: } michael@0: michael@0: if (this._styleSheetToSelect michael@0: && this._styleSheetToSelect.href == editor.styleSheet.href) { michael@0: yield this.switchToSelectedSheet(); michael@0: } michael@0: michael@0: // If this is the first stylesheet and there is no pending request to michael@0: // select a particular style sheet, select this sheet. michael@0: if (!this.selectedEditor && !this._styleSheetBoundToSelect michael@0: && editor.styleSheet.styleSheetIndex == 0) { michael@0: yield this._selectEditor(editor); michael@0: } michael@0: michael@0: this.emit("editor-added", editor); michael@0: }.bind(this)).then(null, Cu.reportError); michael@0: }.bind(this), michael@0: michael@0: onShow: function(summary, details, data) { michael@0: let editor = data.editor; michael@0: this.selectedEditor = editor; michael@0: michael@0: Task.spawn(function* () { michael@0: if (!editor.sourceEditor) { michael@0: // only initialize source editor when we switch to this view michael@0: let inputElement = details.querySelector(".stylesheet-editor-input"); michael@0: yield editor.load(inputElement); michael@0: } michael@0: michael@0: editor.onShow(); michael@0: michael@0: this.emit("editor-selected", editor); michael@0: }.bind(this)).then(null, Cu.reportError); michael@0: }.bind(this) michael@0: }); michael@0: }, michael@0: michael@0: /** michael@0: * Switch to the editor that has been marked to be selected. michael@0: * michael@0: * @return {Promise} michael@0: * Promise that will resolve when the editor is selected. michael@0: */ michael@0: switchToSelectedSheet: function() { michael@0: let sheet = this._styleSheetToSelect; michael@0: michael@0: for (let editor of this.editors) { michael@0: if (editor.styleSheet.href == sheet.href) { michael@0: // The _styleSheetBoundToSelect will always hold the latest pending michael@0: // requested style sheet (with line and column) which is not yet michael@0: // selected by the source editor. Only after we select that particular michael@0: // editor and go the required line and column, it will become null. michael@0: this._styleSheetBoundToSelect = this._styleSheetToSelect; michael@0: this._styleSheetToSelect = null; michael@0: return this._selectEditor(editor, sheet.line, sheet.col); michael@0: } michael@0: } michael@0: michael@0: return promise.resolve(); michael@0: }, michael@0: michael@0: /** michael@0: * Select an editor in the UI. michael@0: * michael@0: * @param {StyleSheetEditor} editor michael@0: * Editor to switch to. michael@0: * @param {number} line michael@0: * Line number to jump to michael@0: * @param {number} col michael@0: * Column number to jump to michael@0: * @return {Promise} michael@0: * Promise that will resolve when the editor is selected. michael@0: */ michael@0: _selectEditor: function(editor, line, col) { michael@0: line = line || 0; michael@0: col = col || 0; michael@0: michael@0: let editorPromise = editor.getSourceEditor().then(() => { michael@0: editor.sourceEditor.setCursor({line: line, ch: col}); michael@0: this._styleSheetBoundToSelect = null; michael@0: }); michael@0: michael@0: let summaryPromise = this.getEditorSummary(editor).then((summary) => { michael@0: this._view.activeSummary = summary; michael@0: }); michael@0: michael@0: return promise.all([editorPromise, summaryPromise]); michael@0: }, michael@0: michael@0: getEditorSummary: function(editor) { michael@0: if (editor.summary) { michael@0: return promise.resolve(editor.summary); michael@0: } michael@0: michael@0: let deferred = promise.defer(); michael@0: let self = this; michael@0: michael@0: this.on("editor-added", function onAdd(e, selected) { michael@0: if (selected == editor) { michael@0: self.off("editor-added", onAdd); michael@0: deferred.resolve(editor.summary); michael@0: } michael@0: }); michael@0: michael@0: return deferred.promise; michael@0: }, michael@0: michael@0: /** michael@0: * Returns an identifier for the given style sheet. michael@0: * michael@0: * @param {StyleSheet} aStyleSheet michael@0: * The style sheet to be identified. michael@0: */ michael@0: getStyleSheetIdentifier: function (aStyleSheet) { michael@0: // Identify inline style sheets by their host page URI and index at the page. michael@0: return aStyleSheet.href ? aStyleSheet.href : michael@0: "inline-" + aStyleSheet.styleSheetIndex + "-at-" + aStyleSheet.nodeHref; michael@0: }, michael@0: michael@0: /** michael@0: * selects a stylesheet and optionally moves the cursor to a selected line michael@0: * michael@0: * @param {string} [href] michael@0: * Href of stylesheet that should be selected. If a stylesheet is not passed michael@0: * and the editor is not initialized we focus the first stylesheet. If michael@0: * a stylesheet is not passed and the editor is initialized we ignore michael@0: * the call. michael@0: * @param {Number} [line] michael@0: * Line to which the caret should be moved (zero-indexed). michael@0: * @param {Number} [col] michael@0: * Column to which the caret should be moved (zero-indexed). michael@0: */ michael@0: selectStyleSheet: function(href, line, col) { michael@0: this._styleSheetToSelect = { michael@0: href: href, michael@0: line: line, michael@0: col: col, michael@0: }; michael@0: michael@0: /* Switch to the editor for this sheet, if it exists yet. michael@0: Otherwise each editor will be checked when it's created. */ michael@0: this.switchToSelectedSheet(); michael@0: }, michael@0: michael@0: michael@0: /** michael@0: * Handler for an editor's 'property-changed' event. michael@0: * Update the summary in the UI. michael@0: * michael@0: * @param {StyleSheetEditor} editor michael@0: * Editor for which a property has changed michael@0: */ michael@0: _summaryChange: function(editor) { michael@0: this._updateSummaryForEditor(editor); michael@0: }, michael@0: michael@0: /** michael@0: * Update split view summary of given StyleEditor instance. michael@0: * michael@0: * @param {StyleSheetEditor} editor michael@0: * @param {DOMElement} summary michael@0: * Optional item's summary element to update. If none, item corresponding michael@0: * to passed editor is used. michael@0: */ michael@0: _updateSummaryForEditor: function(editor, summary) { michael@0: summary = summary || editor.summary; michael@0: if (!summary) { michael@0: return; michael@0: } michael@0: michael@0: let ruleCount = editor.styleSheet.ruleCount; michael@0: if (editor.styleSheet.relatedStyleSheet && editor.linkedCSSFile) { michael@0: ruleCount = editor.styleSheet.relatedStyleSheet.ruleCount; michael@0: } michael@0: if (ruleCount === undefined) { michael@0: ruleCount = "-"; michael@0: } michael@0: michael@0: var flags = []; michael@0: if (editor.styleSheet.disabled) { michael@0: flags.push("disabled"); michael@0: } michael@0: if (editor.unsaved) { michael@0: flags.push("unsaved"); michael@0: } michael@0: if (editor.linkedCSSFileError) { michael@0: flags.push("linked-file-error"); michael@0: } michael@0: this._view.setItemClassName(summary, flags.join(" ")); michael@0: michael@0: let label = summary.querySelector(".stylesheet-name > label"); michael@0: label.setAttribute("value", editor.friendlyName); michael@0: if (editor.styleSheet.href) { michael@0: label.setAttribute("tooltiptext", editor.styleSheet.href); michael@0: } michael@0: michael@0: let linkedCSSFile = ""; michael@0: if (editor.linkedCSSFile) { michael@0: linkedCSSFile = OS.Path.basename(editor.linkedCSSFile); michael@0: } michael@0: text(summary, ".stylesheet-linked-file", linkedCSSFile); michael@0: text(summary, ".stylesheet-title", editor.styleSheet.title || ""); michael@0: text(summary, ".stylesheet-rule-count", michael@0: PluralForm.get(ruleCount, _("ruleCount.label")).replace("#1", ruleCount)); michael@0: }, michael@0: michael@0: destroy: function() { michael@0: this._clearStyleSheetEditors(); michael@0: michael@0: this._prefObserver.off(PREF_ORIG_SOURCES, this._onNewDocument); michael@0: this._prefObserver.destroy(); michael@0: } michael@0: }