/* 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"; const EXPORTED_SYMBOLS = ["StyleEditor", "StyleEditorFlags", "StyleEditorManager"]; const Cc = Components.classes; const Ci = Components.interfaces; const Cu = Components.utils; const DOMUtils = Cc["@mozilla.org/inspector/dom-utils;1"] .getService(Ci.inIDOMUtils); Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/NetUtil.jsm"); Cu.import("resource://gre/modules/FileUtils.jsm"); Cu.import("resource:///modules/devtools/StyleEditorUtil.jsm"); Cu.import("resource:///modules/source-editor.jsm"); const LOAD_ERROR = "error-load"; const SAVE_ERROR = "error-save"; // max update frequency in ms (avoid potential typing lag and/or flicker) // @see StyleEditor.updateStylesheet const UPDATE_STYLESHEET_THROTTLE_DELAY = 500; // @see StyleEditor._persistExpando const STYLESHEET_EXPANDO = "-moz-styleeditor-stylesheet-"; const TRANSITIONS_PREF = "devtools.styleeditor.transitions"; const TRANSITION_CLASS = "moz-styleeditor-transitioning"; const TRANSITION_DURATION_MS = 500; const TRANSITION_RULE = "\ :root.moz-styleeditor-transitioning, :root.moz-styleeditor-transitioning * {\ transition-duration: " + TRANSITION_DURATION_MS + "ms !important; \ transition-delay: 0ms !important;\ transition-timing-function: ease-out !important;\ transition-property: all !important;\ }"; /** * Style Editor module-global preferences */ const TRANSITIONS_ENABLED = Services.prefs.getBoolPref(TRANSITIONS_PREF); /** * StyleEditor constructor. * * The StyleEditor is initialized 'headless', it does not display source * or receive input. Setting inputElement attaches a DOMElement to handle this. * * An editor can be created stand-alone or created by StyleEditorChrome to * manage all the style sheets of a document, including @import'ed sheets. * * @param DOMDocument aDocument * The content document where changes will be applied to. * @param DOMStyleSheet aStyleSheet * Optional. The DOMStyleSheet to edit. * If not set, a new empty style sheet will be appended to the document. * @see inputElement * @see StyleEditorChrome */ function StyleEditor(aDocument, aStyleSheet) { assert(aDocument, "Argument 'aDocument' is required."); this._document = aDocument; // @see contentDocument this._inputElement = null; // @see inputElement this._sourceEditor = null; // @see sourceEditor this._state = { // state to handle inputElement attach/detach text: "", // seamlessly selection: {start: 0, end: 0}, readOnly: false, topIndex: 0, // the first visible line }; this._styleSheet = aStyleSheet; this._styleSheetIndex = -1; // unknown for now, will be set after load this._styleSheetFilePath = null; // original file path for the style sheet this._loaded = false; this._flags = []; // @see flags this._savedFile = null; // @see savedFile this._errorMessage = null; // @see errorMessage // listeners for significant editor actions. @see addActionListener this._actionListeners = []; // this is to perform pending updates before editor closing this._onWindowUnloadBinding = this._onWindowUnload.bind(this); this._transitionRefCount = 0; this._focusOnSourceEditorReady = false; } StyleEditor.prototype = { /** * Retrieve the content document this editor will apply changes to. * * @return DOMDocument */ get contentDocument() this._document, /** * Retrieve the stylesheet this editor is attached to. * * @return DOMStyleSheet */ get styleSheet() { assert(this._styleSheet, "StyleSheet must be loaded first."); return this._styleSheet; }, /** * Retrieve the index (order) of stylesheet in the document. * * @return number */ get styleSheetIndex() { let document = this.contentDocument; if (this._styleSheetIndex == -1) { for (let i = 0; i < document.styleSheets.length; ++i) { if (document.styleSheets[i] == this.styleSheet) { this._styleSheetIndex = i; break; } } } return this._styleSheetIndex; }, /** * Retrieve the input element that handles display and input for this editor. * Can be null if the editor is detached/headless, which means that this * StyleEditor is not attached to an input element. * * @return DOMElement */ get inputElement() this._inputElement, /** * Set the input element that handles display and input for this editor. * This detaches the previous input element if previously set. * * @param DOMElement aElement */ set inputElement(aElement) { if (aElement == this._inputElement) { return; // no change } if (this._inputElement) { // detach from current input element if (this._sourceEditor) { // save existing state first (for seamless reattach) this._state = { text: this._sourceEditor.getText(), selection: this._sourceEditor.getSelection(), readOnly: this._sourceEditor.readOnly, topIndex: this._sourceEditor.getTopIndex(), }; this._sourceEditor.destroy(); this._sourceEditor = null; } this.window.removeEventListener("unload", this._onWindowUnloadBinding, false); this._triggerAction("Detach"); } this._inputElement = aElement; if (!aElement) { return; } // attach to new input element this.window.addEventListener("unload", this._onWindowUnloadBinding, false); this._focusOnSourceEditorReady = false; this._sourceEditor = null; // set it only when ready (safe to use) let sourceEditor = new SourceEditor(); let config = { initialText: this._state.text, showLineNumbers: true, mode: SourceEditor.MODES.CSS, readOnly: this._state.readOnly, keys: this._getKeyBindings() }; sourceEditor.init(aElement, config, function onSourceEditorReady() { setupBracketCompletion(sourceEditor); sourceEditor.addEventListener(SourceEditor.EVENTS.TEXT_CHANGED, function onTextChanged(aEvent) { this.updateStyleSheet(); }.bind(this)); this._sourceEditor = sourceEditor; if (this._focusOnSourceEditorReady) { this._focusOnSourceEditorReady = false; sourceEditor.focus(); } sourceEditor.setTopIndex(this._state.topIndex); sourceEditor.setSelection(this._state.selection.start, this._state.selection.end); this._triggerAction("Attach"); }.bind(this)); }, /** * Retrieve the underlying SourceEditor instance for this StyleEditor. * Can be null if not ready or Style Editor is detached/headless. * * @return SourceEditor */ get sourceEditor() this._sourceEditor, /** * Setter for the read-only state of the editor. * * @param boolean aValue * Tells if you want the editor to be read-only or not. */ set readOnly(aValue) { this._state.readOnly = aValue; if (this._sourceEditor) { this._sourceEditor.readOnly = aValue; } }, /** * Getter for the read-only state of the editor. * * @return boolean */ get readOnly() { return this._state.readOnly; }, /** * Retrieve the window that contains the editor. * Can be null if the editor is detached/headless. * * @return DOMWindow */ get window() { if (!this.inputElement) { return null; } return this.inputElement.ownerDocument.defaultView; }, /** * Retrieve the last file this editor has been saved to or null if none. * * @return nsIFile */ get savedFile() this._savedFile, /** * Import style sheet from file and load it into the editor asynchronously. * "Load" action triggers when complete. * * @param mixed aFile * Optional nsIFile or filename string. * If not set a file picker will be shown. * @param nsIWindow aParentWindow * Optional parent window for the file picker. */ importFromFile: function SE_importFromFile(aFile, aParentWindow) { aFile = this._showFilePicker(aFile, false, aParentWindow); if (!aFile) { return; } this._savedFile = aFile; // remember filename for next save if any NetUtil.asyncFetch(aFile, function onAsyncFetch(aStream, aStatus) { if (!Components.isSuccessCode(aStatus)) { return this._signalError(LOAD_ERROR); } let source = NetUtil.readInputStreamToString(aStream, aStream.available()); aStream.close(); this._appendNewStyleSheet(source); this.clearFlag(StyleEditorFlags.ERROR); }.bind(this)); }, /** * Retrieve localized error message of last error condition, or null if none. * This is set when the editor has flag StyleEditorFlags.ERROR. * * @see addActionListener */ get errorMessage() this._errorMessage, /** * Tell whether the stylesheet has been loaded and ready for modifications. * * @return boolean */ get isLoaded() this._loaded, /** * Load style sheet source into the editor, asynchronously. * "Load" handler triggers when complete. * * @see addActionListener */ load: function SE_load() { if (!this._styleSheet) { this._appendNewStyleSheet(); } this._loadSource(); }, /** * Get a user-friendly name for the style sheet. * * @return string */ getFriendlyName: function SE_getFriendlyName() { if (this.savedFile) { // reuse the saved filename if any return this.savedFile.leafName; } if (this.hasFlag(StyleEditorFlags.NEW)) { let index = this.styleSheetIndex + 1; // 0-indexing only works for devs return _("newStyleSheet", index); } if (this.hasFlag(StyleEditorFlags.INLINE)) { let index = this.styleSheetIndex + 1; // 0-indexing only works for devs return _("inlineStyleSheet", index); } if (!this._friendlyName) { let sheetURI = this.styleSheet.href; let contentURI = this.contentDocument.baseURIObject; let contentURIScheme = contentURI.scheme; let contentURILeafIndex = contentURI.specIgnoringRef.lastIndexOf("/"); contentURI = contentURI.specIgnoringRef; // get content base URI without leaf name (if any) if (contentURILeafIndex > contentURIScheme.length) { contentURI = contentURI.substring(0, contentURILeafIndex + 1); } // avoid verbose repetition of absolute URI when the style sheet URI // is relative to the content URI this._friendlyName = (sheetURI.indexOf(contentURI) == 0) ? sheetURI.substring(contentURI.length) : sheetURI; } return this._friendlyName; }, /** * Add a listener for significant StyleEditor actions. * * The listener implements IStyleEditorActionListener := { * onLoad: Called when the style sheet has been loaded and * parsed. * Arguments: (StyleEditor editor) * @see load * * onFlagChange: Called when a flag has been set or cleared. * Arguments: (StyleEditor editor, string flagName) * @see setFlag * * onAttach: Called when an input element has been attached. * Arguments: (StyleEditor editor) * @see inputElement * * onDetach: Called when input element has been detached. * Arguments: (StyleEditor editor) * @see inputElement * * onUpdate: Called when changes are being applied to the live * DOM style sheet but might not be complete from * a WYSIWYG perspective (eg. transitioned update). * Arguments: (StyleEditor editor) * * onCommit: Called when changes have been completely committed * /applied to the live DOM style sheet. * Arguments: (StyleEditor editor) * } * * All listener methods are optional. * * @param IStyleEditorActionListener aListener * @see removeActionListener */ addActionListener: function SE_addActionListener(aListener) { this._actionListeners.push(aListener); }, /** * Remove a listener for editor actions from the current list of listeners. * * @param IStyleEditorActionListener aListener * @see addActionListener */ removeActionListener: function SE_removeActionListener(aListener) { let index = this._actionListeners.indexOf(aListener); if (index != -1) { this._actionListeners.splice(index, 1); } }, /** * Editor UI flags. * * These are 1-bit indicators that can be used for UI feedback/indicators or * extensions to track the editor status. * Since they are simple strings, they promote loose coupling and can simply * map to CSS class names, which allows to 'expose' indicators declaratively * via CSS (including possibly complex combinations). * * Flag changes can be tracked via onFlagChange (@see addActionListener). * * @see StyleEditorFlags */ /** * Retrieve a space-separated string of all UI flags set on this editor. * * @return string * @see setFlag * @see clearFlag */ get flags() this._flags.join(" "), /** * Set a flag. * * @param string aName * Name of the flag to set. One of StyleEditorFlags members. * @return boolean * True if the flag has been set, false if flag is already set. * @see StyleEditorFlags */ setFlag: function SE_setFlag(aName) { let prop = aName.toUpperCase(); assert(StyleEditorFlags[prop], "Unknown flag: " + prop); if (this.hasFlag(aName)) { return false; } this._flags.push(aName); this._triggerAction("FlagChange", [aName]); return true; }, /** * Clear a flag. * * @param string aName * Name of the flag to clear. * @return boolean * True if the flag has been cleared, false if already clear. */ clearFlag: function SE_clearFlag(aName) { let index = this._flags.indexOf(aName); if (index == -1) { return false; } this._flags.splice(index, 1); this._triggerAction("FlagChange", [aName]); return true; }, /** * Toggle a flag, according to a condition. * * @param aCondition * If true the flag is set, otherwise cleared. * @param string aName * Name of the flag to toggle. * @return boolean * True if the flag has been set or cleared, ie. the flag got switched. */ toggleFlag: function SE_toggleFlag(aCondition, aName) { return (aCondition) ? this.setFlag(aName) : this.clearFlag(aName); }, /** * Check if given flag is set. * * @param string aName * Name of the flag to check presence for. * @return boolean * True if the flag is set, false otherwise. */ hasFlag: function SE_hasFlag(aName) (this._flags.indexOf(aName) != -1), /** * Enable or disable style sheet. * * @param boolean aEnabled */ enableStyleSheet: function SE_enableStyleSheet(aEnabled) { this.styleSheet.disabled = !aEnabled; this.toggleFlag(this.styleSheet.disabled, StyleEditorFlags.DISABLED); if (this._updateTask) { this._updateStyleSheet(); // perform cancelled update } }, /** * Save the editor contents into a file and set savedFile property. * A file picker UI will open if file is not set and editor is not headless. * * @param mixed aFile * Optional nsIFile or string representing the filename to save in the * background, no UI will be displayed. * If not specified, the original style sheet URI is used. * To implement 'Save' instead of 'Save as', you can pass savedFile here. * @param function(nsIFile aFile) aCallback * Optional callback called when the operation has finished. * aFile has the nsIFile object for saved file or null if the operation * has failed or has been canceled by the user. * @see savedFile */ saveToFile: function SE_saveToFile(aFile, aCallback) { aFile = this._showFilePicker(aFile || this._styleSheetFilePath, true); if (!aFile) { if (aCallback) { aCallback(null); } return; } if (this._sourceEditor) { this._state.text = this._sourceEditor.getText(); } let ostream = FileUtils.openSafeFileOutputStream(aFile); let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"] .createInstance(Ci.nsIScriptableUnicodeConverter); converter.charset = "UTF-8"; let istream = converter.convertToInputStream(this._state.text); NetUtil.asyncCopy(istream, ostream, function SE_onStreamCopied(status) { if (!Components.isSuccessCode(status)) { if (aCallback) { aCallback(null); } this._signalError(SAVE_ERROR); return; } FileUtils.closeSafeFileOutputStream(ostream); // remember filename for next save if any this._friendlyName = null; this._savedFile = aFile; this._persistExpando(); if (aCallback) { aCallback(aFile); } this.clearFlag(StyleEditorFlags.UNSAVED); this.clearFlag(StyleEditorFlags.ERROR); }.bind(this)); }, /** * Queue a throttled task to update the live style sheet. * * @param boolean aImmediate * Optional. If true the update is performed immediately. */ updateStyleSheet: function SE_updateStyleSheet(aImmediate) { let window = this.window; if (this._updateTask) { // cancel previous queued task not executed within throttle delay window.clearTimeout(this._updateTask); } if (aImmediate) { this._updateStyleSheet(); } else { this._updateTask = window.setTimeout(this._updateStyleSheet.bind(this), UPDATE_STYLESHEET_THROTTLE_DELAY); } }, /** * Update live style sheet according to modifications. */ _updateStyleSheet: function SE__updateStyleSheet() { this.setFlag(StyleEditorFlags.UNSAVED); if (this.styleSheet.disabled) { return; } this._updateTask = null; // reset only if we actually perform an update // (stylesheet is enabled) so that 'missed' updates // while the stylesheet is disabled can be performed // when it is enabled back. @see enableStylesheet if (this.sourceEditor) { this._state.text = this.sourceEditor.getText(); } DOMUtils.parseStyleSheet(this.styleSheet, this._state.text); this._persistExpando(); if (!TRANSITIONS_ENABLED) { this._triggerAction("Update"); this._triggerAction("Commit"); return; } let content = this.contentDocument; // Insert the global transition rule // Use a ref count to make sure we do not add it multiple times.. and remove // it only when all pending StyleEditor-generated transitions ended. if (!this._transitionRefCount) { this._styleSheet.insertRule(TRANSITION_RULE, 0); content.documentElement.classList.add(TRANSITION_CLASS); } this._transitionRefCount++; // Set up clean up and commit after transition duration (+10% buffer) // @see _onTransitionEnd content.defaultView.setTimeout(this._onTransitionEnd.bind(this), Math.floor(TRANSITION_DURATION_MS * 1.1)); this._triggerAction("Update"); }, /** * This cleans up class and rule added for transition effect and then trigger * Commit as the changes have been completed. */ _onTransitionEnd: function SE__onTransitionEnd() { if (--this._transitionRefCount == 0) { this.contentDocument.documentElement.classList.remove(TRANSITION_CLASS); this.styleSheet.deleteRule(0); } this._triggerAction("Commit"); }, /** * Show file picker and return the file user selected. * * @param mixed aFile * Optional nsIFile or string representing the filename to auto-select. * @param boolean aSave * If true, the user is selecting a filename to save. * @param nsIWindow aParentWindow * Optional parent window. If null the parent window of the file picker * will be the window of the attached input element. * @return nsIFile * The selected file or null if the user did not pick one. */ _showFilePicker: function SE__showFilePicker(aFile, aSave, aParentWindow) { if (typeof(aFile) == "string") { try { if (Services.io.extractScheme(aFile) == "file") { let uri = Services.io.newURI(aFile, null, null); let file = uri.QueryInterface(Ci.nsIFileURL).file; return file; } } catch (ex) { } try { let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsILocalFile); file.initWithPath(aFile); return file; } catch (ex) { this._signalError(aSave ? SAVE_ERROR : LOAD_ERROR); return null; } } if (aFile) { return aFile; } let window = aParentWindow ? aParentWindow : this.inputElement.ownerDocument.defaultView; let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); let mode = aSave ? fp.modeSave : fp.modeOpen; let key = aSave ? "saveStyleSheet" : "importStyleSheet"; fp.init(window, _(key + ".title"), mode); fp.appendFilters(_(key + ".filter"), "*.css"); fp.appendFilters(fp.filterAll); let rv = fp.show(); return (rv == fp.returnCancel) ? null : fp.file; }, /** * Retrieve the style sheet source from the cache or from a local file. */ _loadSource: function SE__loadSource() { if (!this.styleSheet.href) { // this is an inline