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