browser/devtools/styleeditor/StyleSheetEditor.jsm

Wed, 31 Dec 2014 06:09:35 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Wed, 31 Dec 2014 06:09:35 +0100
changeset 0
6474c204b198
permissions
-rw-r--r--

Cloned upstream origin tor-browser at tor-browser-31.3.0esr-4.5-1-build1
revision ID fc1c9ff7c1b2defdbc039f12214767608f46423f for hacking purpose.

     1 /* vim:set ts=2 sw=2 sts=2 et: */
     2 /* This Source Code Form is subject to the terms of the Mozilla Public
     3  * License, v. 2.0. If a copy of the MPL was not distributed with this
     4  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
     6 "use strict";
     8 this.EXPORTED_SYMBOLS = ["StyleSheetEditor"];
    10 const Cc = Components.classes;
    11 const Ci = Components.interfaces;
    12 const Cu = Components.utils;
    14 const require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require;
    15 const Editor  = require("devtools/sourceeditor/editor");
    16 const {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {});
    17 const {CssLogic} = require("devtools/styleinspector/css-logic");
    18 const AutoCompleter = require("devtools/sourceeditor/autocomplete");
    20 Cu.import("resource://gre/modules/Services.jsm");
    21 Cu.import("resource://gre/modules/FileUtils.jsm");
    22 Cu.import("resource://gre/modules/NetUtil.jsm");
    23 Cu.import("resource://gre/modules/osfile.jsm");
    24 Cu.import("resource://gre/modules/devtools/event-emitter.js");
    25 Cu.import("resource:///modules/devtools/StyleEditorUtil.jsm");
    27 const LOAD_ERROR = "error-load";
    28 const SAVE_ERROR = "error-save";
    30 // max update frequency in ms (avoid potential typing lag and/or flicker)
    31 // @see StyleEditor.updateStylesheet
    32 const UPDATE_STYLESHEET_THROTTLE_DELAY = 500;
    34 // Pref which decides if CSS autocompletion is enabled in Style Editor or not.
    35 const AUTOCOMPLETION_PREF = "devtools.styleeditor.autocompletion-enabled";
    37 // How long to wait to update linked CSS file after original source was saved
    38 // to disk. Time in ms.
    39 const CHECK_LINKED_SHEET_DELAY=500;
    41 // How many times to check for linked file changes
    42 const MAX_CHECK_COUNT=10;
    44 /**
    45  * StyleSheetEditor controls the editor linked to a particular StyleSheet
    46  * object.
    47  *
    48  * Emits events:
    49  *   'property-change': A property on the underlying stylesheet has changed
    50  *   'source-editor-load': The source editor for this editor has been loaded
    51  *   'error': An error has occured
    52  *
    53  * @param {StyleSheet|OriginalSource}  styleSheet
    54  *        Stylesheet or original source to show
    55  * @param {DOMWindow}  win
    56  *        panel window for style editor
    57  * @param {nsIFile}  file
    58  *        Optional file that the sheet was imported from
    59  * @param {boolean} isNew
    60  *        Optional whether the sheet was created by the user
    61  * @param {Walker} walker
    62  *        Optional walker used for selectors autocompletion
    63  */
    64 function StyleSheetEditor(styleSheet, win, file, isNew, walker) {
    65   EventEmitter.decorate(this);
    67   this.styleSheet = styleSheet;
    68   this._inputElement = null;
    69   this.sourceEditor = null;
    70   this._window = win;
    71   this._isNew = isNew;
    72   this.walker = walker;
    74   this._state = {   // state to use when inputElement attaches
    75     text: "",
    76     selection: {
    77       start: {line: 0, ch: 0},
    78       end: {line: 0, ch: 0}
    79     },
    80     topIndex: 0              // the first visible line
    81   };
    83   this._styleSheetFilePath = null;
    84   if (styleSheet.href &&
    85       Services.io.extractScheme(this.styleSheet.href) == "file") {
    86     this._styleSheetFilePath = this.styleSheet.href;
    87   }
    89   this._onPropertyChange = this._onPropertyChange.bind(this);
    90   this._onError = this._onError.bind(this);
    91   this.checkLinkedFileForChanges = this.checkLinkedFileForChanges.bind(this);
    92   this.markLinkedFileBroken = this.markLinkedFileBroken.bind(this);
    94   this._focusOnSourceEditorReady = false;
    96   let relatedSheet = this.styleSheet.relatedStyleSheet;
    97   if (relatedSheet) {
    98     relatedSheet.on("property-change", this._onPropertyChange);
    99   }
   100   this.styleSheet.on("property-change", this._onPropertyChange);
   101   this.styleSheet.on("error", this._onError);
   103   this.savedFile = file;
   104   this.linkCSSFile();
   105 }
   107 StyleSheetEditor.prototype = {
   108   /**
   109    * Whether there are unsaved changes in the editor
   110    */
   111   get unsaved() {
   112     return this.sourceEditor && !this.sourceEditor.isClean();
   113   },
   115   /**
   116    * Whether the editor is for a stylesheet created by the user
   117    * through the style editor UI.
   118    */
   119   get isNew() {
   120     return this._isNew;
   121   },
   123   get savedFile() {
   124     return this._savedFile;
   125   },
   127   set savedFile(name) {
   128     this._savedFile = name;
   130     this.linkCSSFile();
   131   },
   133   /**
   134    * Get a user-friendly name for the style sheet.
   135    *
   136    * @return string
   137    */
   138   get friendlyName() {
   139     if (this.savedFile) {
   140       return this.savedFile.leafName;
   141     }
   143     if (this._isNew) {
   144       let index = this.styleSheet.styleSheetIndex + 1;
   145       return _("newStyleSheet", index);
   146     }
   148     if (!this.styleSheet.href) {
   149       let index = this.styleSheet.styleSheetIndex + 1;
   150       return _("inlineStyleSheet", index);
   151     }
   153     if (!this._friendlyName) {
   154       let sheetURI = this.styleSheet.href;
   155       this._friendlyName = CssLogic.shortSource({ href: sheetURI });
   156       try {
   157         this._friendlyName = decodeURI(this._friendlyName);
   158       } catch (ex) {
   159       }
   160     }
   161     return this._friendlyName;
   162   },
   164   /**
   165    * If this is an original source, get the path of the CSS file it generated.
   166    */
   167   linkCSSFile: function() {
   168     if (!this.styleSheet.isOriginalSource) {
   169       return;
   170     }
   172     let relatedSheet = this.styleSheet.relatedStyleSheet;
   174     let path;
   175     let href = removeQuery(relatedSheet.href);
   176     let uri = NetUtil.newURI(href);
   178     if (uri.scheme == "file") {
   179       let file = uri.QueryInterface(Ci.nsIFileURL).file;
   180       path = file.path;
   181     }
   182     else if (this.savedFile) {
   183       let origHref = removeQuery(this.styleSheet.href);
   184       let origUri = NetUtil.newURI(origHref);
   185       path = findLinkedFilePath(uri, origUri, this.savedFile);
   186     }
   187     else {
   188       // we can't determine path to generated file on disk
   189       return;
   190     }
   192     if (this.linkedCSSFile == path) {
   193       return;
   194     }
   196     this.linkedCSSFile = path;
   198     this.linkedCSSFileError = null;
   200     // save last file change time so we can compare when we check for changes.
   201     OS.File.stat(path).then((info) => {
   202       this._fileModDate = info.lastModificationDate.getTime();
   203     }, this.markLinkedFileBroken);
   205     this.emit("linked-css-file");
   206   },
   208   /**
   209    * Start fetching the full text source for this editor's sheet.
   210    */
   211   fetchSource: function(callback) {
   212     this.styleSheet.getText().then((longStr) => {
   213       longStr.string().then((source) => {
   214         let ruleCount = this.styleSheet.ruleCount;
   215         this._state.text = prettifyCSS(source, ruleCount);
   216         this.sourceLoaded = true;
   218         callback(source);
   219       });
   220     }, e => {
   221       this.emit("error", LOAD_ERROR, this.styleSheet.href);
   222     })
   223   },
   225   /**
   226    * Forward property-change event from stylesheet.
   227    *
   228    * @param  {string} event
   229    *         Event type
   230    * @param  {string} property
   231    *         Property that has changed on sheet
   232    */
   233   _onPropertyChange: function(property, value) {
   234     this.emit("property-change", property, value);
   235   },
   237   /**
   238    * Forward error event from stylesheet.
   239    *
   240    * @param  {string} event
   241    *         Event type
   242    * @param  {string} errorCode
   243    */
   244   _onError: function(event, errorCode) {
   245     this.emit("error", errorCode);
   246   },
   248   /**
   249    * Create source editor and load state into it.
   250    * @param  {DOMElement} inputElement
   251    *         Element to load source editor in
   252    *
   253    * @return {Promise}
   254    *         Promise that will resolve when the style editor is loaded.
   255    */
   256   load: function(inputElement) {
   257     this._inputElement = inputElement;
   259     let config = {
   260       value: this._state.text,
   261       lineNumbers: true,
   262       mode: Editor.modes.css,
   263       readOnly: false,
   264       autoCloseBrackets: "{}()[]",
   265       extraKeys: this._getKeyBindings(),
   266       contextMenu: "sourceEditorContextMenu"
   267     };
   268     let sourceEditor = new Editor(config);
   270     sourceEditor.on("dirty-change", this._onPropertyChange);
   272     return sourceEditor.appendTo(inputElement).then(() => {
   273       if (Services.prefs.getBoolPref(AUTOCOMPLETION_PREF)) {
   274         sourceEditor.extend(AutoCompleter);
   275         sourceEditor.setupAutoCompletion(this.walker);
   276       }
   277       sourceEditor.on("save", () => {
   278         this.saveToFile();
   279       });
   281       if (this.styleSheet.update) {
   282         sourceEditor.on("change", () => {
   283           this.updateStyleSheet();
   284         });
   285       }
   287       this.sourceEditor = sourceEditor;
   289       if (this._focusOnSourceEditorReady) {
   290         this._focusOnSourceEditorReady = false;
   291         sourceEditor.focus();
   292       }
   294       sourceEditor.setFirstVisibleLine(this._state.topIndex);
   295       sourceEditor.setSelection(this._state.selection.start,
   296                                 this._state.selection.end);
   298       this.emit("source-editor-load");
   299     });
   300   },
   302   /**
   303    * Get the source editor for this editor.
   304    *
   305    * @return {Promise}
   306    *         Promise that will resolve with the editor.
   307    */
   308   getSourceEditor: function() {
   309     let deferred = promise.defer();
   311     if (this.sourceEditor) {
   312       return promise.resolve(this);
   313     }
   314     this.on("source-editor-load", () => {
   315       deferred.resolve(this);
   316     });
   317     return deferred.promise;
   318   },
   320   /**
   321    * Focus the Style Editor input.
   322    */
   323   focus: function() {
   324     if (this.sourceEditor) {
   325       this.sourceEditor.focus();
   326     } else {
   327       this._focusOnSourceEditorReady = true;
   328     }
   329   },
   331   /**
   332    * Event handler for when the editor is shown.
   333    */
   334   onShow: function() {
   335     if (this.sourceEditor) {
   336       this.sourceEditor.setFirstVisibleLine(this._state.topIndex);
   337     }
   338     this.focus();
   339   },
   341   /**
   342    * Toggled the disabled state of the underlying stylesheet.
   343    */
   344   toggleDisabled: function() {
   345     this.styleSheet.toggleDisabled();
   346   },
   348   /**
   349    * Queue a throttled task to update the live style sheet.
   350    *
   351    * @param boolean immediate
   352    *        Optional. If true the update is performed immediately.
   353    */
   354   updateStyleSheet: function(immediate) {
   355     if (this._updateTask) {
   356       // cancel previous queued task not executed within throttle delay
   357       this._window.clearTimeout(this._updateTask);
   358     }
   360     if (immediate) {
   361       this._updateStyleSheet();
   362     } else {
   363       this._updateTask = this._window.setTimeout(this._updateStyleSheet.bind(this),
   364                                            UPDATE_STYLESHEET_THROTTLE_DELAY);
   365     }
   366   },
   368   /**
   369    * Update live style sheet according to modifications.
   370    */
   371   _updateStyleSheet: function() {
   372     if (this.styleSheet.disabled) {
   373       return;  // TODO: do we want to do this?
   374     }
   376     this._updateTask = null; // reset only if we actually perform an update
   377                              // (stylesheet is enabled) so that 'missed' updates
   378                              // while the stylesheet is disabled can be performed
   379                              // when it is enabled back. @see enableStylesheet
   381     if (this.sourceEditor) {
   382       this._state.text = this.sourceEditor.getText();
   383     }
   385     this.styleSheet.update(this._state.text, true);
   386   },
   388   /**
   389    * Save the editor contents into a file and set savedFile property.
   390    * A file picker UI will open if file is not set and editor is not headless.
   391    *
   392    * @param mixed file
   393    *        Optional nsIFile or string representing the filename to save in the
   394    *        background, no UI will be displayed.
   395    *        If not specified, the original style sheet URI is used.
   396    *        To implement 'Save' instead of 'Save as', you can pass savedFile here.
   397    * @param function(nsIFile aFile) callback
   398    *        Optional callback called when the operation has finished.
   399    *        aFile has the nsIFile object for saved file or null if the operation
   400    *        has failed or has been canceled by the user.
   401    * @see savedFile
   402    */
   403   saveToFile: function(file, callback) {
   404     let onFile = (returnFile) => {
   405       if (!returnFile) {
   406         if (callback) {
   407           callback(null);
   408         }
   409         return;
   410       }
   412       if (this.sourceEditor) {
   413         this._state.text = this.sourceEditor.getText();
   414       }
   416       let ostream = FileUtils.openSafeFileOutputStream(returnFile);
   417       let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]
   418                         .createInstance(Ci.nsIScriptableUnicodeConverter);
   419       converter.charset = "UTF-8";
   420       let istream = converter.convertToInputStream(this._state.text);
   422       NetUtil.asyncCopy(istream, ostream, function onStreamCopied(status) {
   423         if (!Components.isSuccessCode(status)) {
   424           if (callback) {
   425             callback(null);
   426           }
   427           this.emit("error", SAVE_ERROR);
   428           return;
   429         }
   430         FileUtils.closeSafeFileOutputStream(ostream);
   432         this.onFileSaved(returnFile);
   434         if (callback) {
   435           callback(returnFile);
   436         }
   437       }.bind(this));
   438     };
   440     let defaultName;
   441     if (this._friendlyName) {
   442       defaultName = OS.Path.basename(this._friendlyName);
   443     }
   444     showFilePicker(file || this._styleSheetFilePath, true, this._window,
   445                    onFile, defaultName);
   446   },
   448   /**
   449    * Called when this source has been successfully saved to disk.
   450    */
   451   onFileSaved: function(returnFile) {
   452     this._friendlyName = null;
   453     this.savedFile = returnFile;
   455     this.sourceEditor.setClean();
   457     this.emit("property-change");
   459     // TODO: replace with file watching
   460     this._modCheckCount = 0;
   461     this._window.clearTimeout(this._timeout);
   463     if (this.linkedCSSFile && !this.linkedCSSFileError) {
   464       this._timeout = this._window.setTimeout(this.checkLinkedFileForChanges,
   465                                               CHECK_LINKED_SHEET_DELAY);
   466     }
   467   },
   469   /**
   470    * Check to see if our linked CSS file has changed on disk, and
   471    * if so, update the live style sheet.
   472    */
   473   checkLinkedFileForChanges: function() {
   474     OS.File.stat(this.linkedCSSFile).then((info) => {
   475       let lastChange = info.lastModificationDate.getTime();
   477       if (this._fileModDate && lastChange != this._fileModDate) {
   478         this._fileModDate = lastChange;
   479         this._modCheckCount = 0;
   481         this.updateLinkedStyleSheet();
   482         return;
   483       }
   485       if (++this._modCheckCount > MAX_CHECK_COUNT) {
   486         this.updateLinkedStyleSheet();
   487         return;
   488       }
   490       // try again in a bit
   491       this._timeout = this._window.setTimeout(this.checkLinkedFileForChanges,
   492                                               CHECK_LINKED_SHEET_DELAY);
   493     }, this.markLinkedFileBroken);
   494   },
   496   /**
   497    * Notify that the linked CSS file (if this is an original source)
   498    * doesn't exist on disk in the place we think it does.
   499    *
   500    * @param string error
   501    *        The error we got when trying to access the file.
   502    */
   503   markLinkedFileBroken: function(error) {
   504     this.linkedCSSFileError = error || true;
   505     this.emit("linked-css-file-error");
   507     error += " querying " + this.linkedCSSFile +
   508              " original source location: " + this.savedFile.path
   509     Cu.reportError(error);
   510   },
   512   /**
   513    * For original sources (e.g. Sass files). Fetch contents of linked CSS
   514    * file from disk and live update the stylesheet object with the contents.
   515    */
   516   updateLinkedStyleSheet: function() {
   517     OS.File.read(this.linkedCSSFile).then((array) => {
   518       let decoder = new TextDecoder();
   519       let text = decoder.decode(array);
   521       let relatedSheet = this.styleSheet.relatedStyleSheet;
   522       relatedSheet.update(text, true);
   523     }, this.markLinkedFileBroken);
   524   },
   526   /**
   527     * Retrieve custom key bindings objects as expected by Editor.
   528     * Editor action names are not displayed to the user.
   529     *
   530     * @return {array} key binding objects for the source editor
   531     */
   532   _getKeyBindings: function() {
   533     let bindings = {};
   535     bindings[Editor.accel(_("saveStyleSheet.commandkey"))] = () => {
   536       this.saveToFile(this.savedFile);
   537     };
   539     bindings["Shift-" + Editor.accel(_("saveStyleSheet.commandkey"))] = () => {
   540       this.saveToFile();
   541     };
   543     return bindings;
   544   },
   546   /**
   547    * Clean up for this editor.
   548    */
   549   destroy: function() {
   550     if (this.sourceEditor) {
   551       this.sourceEditor.destroy();
   552     }
   553     this.styleSheet.off("property-change", this._onPropertyChange);
   554     this.styleSheet.off("error", this._onError);
   555   }
   556 }
   559 const TAB_CHARS = "\t";
   561 const CURRENT_OS = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime).OS;
   562 const LINE_SEPARATOR = CURRENT_OS === "WINNT" ? "\r\n" : "\n";
   564 /**
   565  * Prettify minified CSS text.
   566  * This prettifies CSS code where there is no indentation in usual places while
   567  * keeping original indentation as-is elsewhere.
   568  *
   569  * @param string text
   570  *        The CSS source to prettify.
   571  * @return string
   572  *         Prettified CSS source
   573  */
   574 function prettifyCSS(text, ruleCount)
   575 {
   576   // remove initial and terminating HTML comments and surrounding whitespace
   577   text = text.replace(/(?:^\s*<!--[\r\n]*)|(?:\s*-->\s*$)/g, "");
   579   // don't attempt to prettify if there's more than one line per rule.
   580   let lineCount = text.split("\n").length - 1;
   581   if (ruleCount !== null && lineCount >= ruleCount) {
   582     return text;
   583   }
   585   let parts = [];    // indented parts
   586   let partStart = 0; // start offset of currently parsed part
   587   let indent = "";
   588   let indentLevel = 0;
   590   for (let i = 0; i < text.length; i++) {
   591     let c = text[i];
   592     let shouldIndent = false;
   594     switch (c) {
   595       case "}":
   596         if (i - partStart > 1) {
   597           // there's more than just } on the line, add line
   598           parts.push(indent + text.substring(partStart, i));
   599           partStart = i;
   600         }
   601         indent = TAB_CHARS.repeat(--indentLevel);
   602         /* fallthrough */
   603       case ";":
   604       case "{":
   605         shouldIndent = true;
   606         break;
   607     }
   609     if (shouldIndent) {
   610       let la = text[i+1]; // one-character lookahead
   611       if (!/\n/.test(la) || /^\s+$/.test(text.substring(i+1, text.length))) {
   612         // following character should be a new line, but isn't,
   613         // or it's whitespace at the end of the file
   614         parts.push(indent + text.substring(partStart, i + 1));
   615         if (c == "}") {
   616           parts.push(""); // for extra line separator
   617         }
   618         partStart = i + 1;
   619       } else {
   620         return text; // assume it is not minified, early exit
   621       }
   622     }
   624     if (c == "{") {
   625       indent = TAB_CHARS.repeat(++indentLevel);
   626     }
   627   }
   628   return parts.join(LINE_SEPARATOR);
   629 }
   631 /**
   632  * Find a path on disk for a file given it's hosted uri, the uri of the
   633  * original resource that generated it (e.g. Sass file), and the location of the
   634  * local file for that source.
   635  *
   636  * @param {nsIURI} uri
   637  *        The uri of the resource
   638  * @param {nsIURI} origUri
   639  *        The uri of the original source for the resource
   640  * @param {nsIFile} file
   641  *        The local file for the resource on disk
   642  *
   643  * @return {string}
   644  *         The path of original file on disk
   645  */
   646 function findLinkedFilePath(uri, origUri, file) {
   647   let { origBranch, branch } = findUnsharedBranches(origUri, uri);
   648   let project = findProjectPath(file, origBranch);
   650   let parts = project.concat(branch);
   651   let path = OS.Path.join.apply(this, parts);
   653   return path;
   654 }
   656 /**
   657  * Find the path of a project given a file in the project and its branch
   658  * off the root. e.g.:
   659  * /Users/moz/proj/src/a.css" and "src/a.css"
   660  * would yield ["Users", "moz", "proj"]
   661  *
   662  * @param {nsIFile} file
   663  *        file for that resource on disk
   664  * @param {array} branch
   665  *        path parts for branch to chop off file path.
   666  * @return {array}
   667  *        array of path parts
   668  */
   669 function findProjectPath(file, branch) {
   670   let path = OS.Path.split(file.path).components;
   672   for (let i = 2; i <= branch.length; i++) {
   673     // work backwards until we find a differing directory name
   674     if (path[path.length - i] != branch[branch.length - i]) {
   675       return path.slice(0, path.length - i + 1);
   676     }
   677   }
   679   // if we don't find a differing directory, just chop off the branch
   680   return path.slice(0, path.length - branch.length);
   681 }
   683 /**
   684  * Find the parts of a uri past the root it shares with another uri. e.g:
   685  * "http://localhost/built/a.scss" and "http://localhost/src/a.css"
   686  * would yield ["built", "a.scss"] and ["src", "a.css"]
   687  *
   688  * @param {nsIURI} origUri
   689  *        uri to find unshared branch of. Usually is uri for original source.
   690  * @param {nsIURI} uri
   691  *        uri to compare against to get a shared root
   692  * @return {object}
   693  *         object with 'branch' and 'origBranch' array of path parts for branch
   694  */
   695 function findUnsharedBranches(origUri, uri) {
   696   origUri = OS.Path.split(origUri.path).components;
   697   uri = OS.Path.split(uri.path).components;
   699   for (let i = 0; i < uri.length - 1; i++) {
   700     if (uri[i] != origUri[i]) {
   701       return {
   702         branch: uri.slice(i),
   703         origBranch: origUri.slice(i)
   704       };
   705     }
   706   }
   707   return {
   708     branch: uri,
   709     origBranch: origUri
   710   };
   711 }
   713 /**
   714  * Remove the query string from a url.
   715  *
   716  * @param  {string} href
   717  *         Url to remove query string from
   718  * @return {string}
   719  *         Url without query string
   720  */
   721 function removeQuery(href) {
   722   return href.replace(/\?.*/, "");
   723 }

mercurial