diff options
Diffstat (limited to 'devtools/client/styleeditor/StyleEditorUI.jsm')
-rw-r--r-- | devtools/client/styleeditor/StyleEditorUI.jsm | 1029 |
1 files changed, 1029 insertions, 0 deletions
diff --git a/devtools/client/styleeditor/StyleEditorUI.jsm b/devtools/client/styleeditor/StyleEditorUI.jsm new file mode 100644 index 000000000..cdb267669 --- /dev/null +++ b/devtools/client/styleeditor/StyleEditorUI.jsm @@ -0,0 +1,1029 @@ +/* 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 Ci = Components.interfaces; +const Cu = Components.utils; + +const {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); +const Services = require("Services"); +const {NetUtil} = require("resource://gre/modules/NetUtil.jsm"); +const {OS} = require("resource://gre/modules/osfile.jsm"); +const {Task} = require("devtools/shared/task"); +const EventEmitter = require("devtools/shared/event-emitter"); +const {gDevTools} = require("devtools/client/framework/devtools"); +const { + getString, + text, + wire, + showFilePicker, +} = require("resource://devtools/client/styleeditor/StyleEditorUtil.jsm"); +const {SplitView} = require("resource://devtools/client/shared/SplitView.jsm"); +const {StyleSheetEditor} = require("resource://devtools/client/styleeditor/StyleSheetEditor.jsm"); +const {PluralForm} = require("devtools/shared/plural-form"); +const {PrefObserver, PREF_ORIG_SOURCES} = require("devtools/client/styleeditor/utils"); +const csscoverage = require("devtools/shared/fronts/csscoverage"); +const {console} = require("resource://gre/modules/Console.jsm"); +const promise = require("promise"); +const defer = require("devtools/shared/defer"); +const {ResponsiveUIManager} = + require("resource://devtools/client/responsivedesign/responsivedesign.jsm"); +const {KeyCodes} = require("devtools/client/shared/keycodes"); + +const LOAD_ERROR = "error-load"; +const STYLE_EDITOR_TEMPLATE = "stylesheet"; +const SELECTOR_HIGHLIGHTER_TYPE = "SelectorHighlighter"; +const PREF_MEDIA_SIDEBAR = "devtools.styleeditor.showMediaSidebar"; +const PREF_SIDEBAR_WIDTH = "devtools.styleeditor.mediaSidebarWidth"; +const PREF_NAV_WIDTH = "devtools.styleeditor.navSidebarWidth"; + +/** + * 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. + * @param {CssProperties} A css properties database. + */ +function StyleEditorUI(debuggee, target, panelDoc, cssProperties) { + EventEmitter.decorate(this); + + this._debuggee = debuggee; + this._target = target; + this._panelDoc = panelDoc; + this._cssProperties = cssProperties; + this._window = this._panelDoc.defaultView; + this._root = this._panelDoc.getElementById("style-editor-chrome"); + + this.editors = []; + this.selectedEditor = null; + this.savedLocations = {}; + + this._onOptionsPopupShowing = this._onOptionsPopupShowing.bind(this); + this._onOptionsPopupHiding = this._onOptionsPopupHiding.bind(this); + this._onStyleSheetCreated = this._onStyleSheetCreated.bind(this); + this._onNewDocument = this._onNewDocument.bind(this); + this._onMediaPrefChanged = this._onMediaPrefChanged.bind(this); + this._updateMediaList = this._updateMediaList.bind(this); + this._clear = this._clear.bind(this); + this._onError = this._onError.bind(this); + this._updateOpenLinkItem = this._updateOpenLinkItem.bind(this); + this._openLinkNewTab = this._openLinkNewTab.bind(this); + + this._prefObserver = new PrefObserver("devtools.styleeditor."); + this._prefObserver.on(PREF_ORIG_SOURCES, this._onNewDocument); + this._prefObserver.on(PREF_MEDIA_SIDEBAR, this._onMediaPrefChanged); +} +this.StyleEditorUI = StyleEditorUI; + +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, the inspector front to get + * reference to the walker and the selector highlighter if available + */ + initialize: Task.async(function* () { + yield this.initializeHighlighter(); + + this.createUI(); + + let styleSheets = yield this._debuggee.getStyleSheets(); + yield this._resetStyleSheetList(styleSheets); + + this._target.on("will-navigate", this._clear); + this._target.on("navigate", this._onNewDocument); + }), + + initializeHighlighter: Task.async(function* () { + let toolbox = gDevTools.getToolbox(this._target); + yield toolbox.initInspector(); + this._walker = toolbox.walker; + + let hUtils = toolbox.highlighterUtils; + if (hUtils.supportsCustomHighlighters()) { + try { + this._highlighter = + yield hUtils.getHighlighterByType(SELECTOR_HIGHLIGHTER_TYPE); + } catch (e) { + // The selectorHighlighter can't always be instantiated, for example + // it doesn't work with XUL windows (until bug 1094959 gets fixed); + // or the selectorHighlighter doesn't exist on the backend. + console.warn("The selectorHighlighter couldn't be instantiated, " + + "elements matching hovered selectors will not be highlighted"); + } + } + }), + + /** + * 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", () =>{ + this._debuggee.addStyleSheet(null).then(this._onStyleSheetCreated); + }); + + wire(this._view.rootElement, ".style-editor-importButton", ()=> { + this._importFromFile(this._mockImportFile || null, this._window); + }); + + this._optionsButton = this._panelDoc.getElementById("style-editor-options"); + this._panelDoc.addEventListener("contextmenu", () => { + this._contextMenuStyleSheet = null; + }, true); + + this._contextMenu = this._panelDoc.getElementById("sidebar-context"); + this._contextMenu.addEventListener("popupshowing", + this._updateOpenLinkItem); + + this._optionsMenu = + this._panelDoc.getElementById("style-editor-options-popup"); + this._optionsMenu.addEventListener("popupshowing", + this._onOptionsPopupShowing); + this._optionsMenu.addEventListener("popuphiding", + this._onOptionsPopupHiding); + + this._sourcesItem = this._panelDoc.getElementById("options-origsources"); + this._sourcesItem.addEventListener("command", + this._toggleOrigSources); + + this._mediaItem = this._panelDoc.getElementById("options-show-media"); + this._mediaItem.addEventListener("command", + this._toggleMediaSidebar); + + this._openLinkNewTabItem = + this._panelDoc.getElementById("context-openlinknewtab"); + this._openLinkNewTabItem.addEventListener("command", + this._openLinkNewTab); + + let nav = this._panelDoc.querySelector(".splitview-controller"); + nav.setAttribute("width", Services.prefs.getIntPref(PREF_NAV_WIDTH)); + }, + + /** + * Listener handling the 'gear menu' popup showing event. + * Update options menu items to reflect current preference settings. + */ + _onOptionsPopupShowing: function () { + this._optionsButton.setAttribute("open", "true"); + this._sourcesItem.setAttribute("checked", + Services.prefs.getBoolPref(PREF_ORIG_SOURCES)); + this._mediaItem.setAttribute("checked", + Services.prefs.getBoolPref(PREF_MEDIA_SIDEBAR)); + }, + + /** + * Listener handling the 'gear menu' popup hiding event. + */ + _onOptionsPopupHiding: function () { + this._optionsButton.removeAttribute("open"); + }, + + /** + * 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) => { + return this._resetStyleSheetList(styleSheets); + }).then(null, e => console.error(e)); + }, + + /** + * Add editors for all the given stylesheets to the UI. + * + * @param {array} styleSheets + * Array of StyleSheetFront + */ + _resetStyleSheetList: Task.async(function* (styleSheets) { + this._clear(); + + for (let sheet of styleSheets) { + try { + yield this._addStyleSheet(sheet); + } catch (e) { + this.emit("error", { key: LOAD_ERROR }); + } + } + + 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 = { + stylesheet: 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: Task.async(function* (styleSheet) { + let editor = yield this._addStyleSheetEditor(styleSheet); + + if (!Services.prefs.getBoolPref(PREF_ORIG_SOURCES)) { + return; + } + + let sources = yield styleSheet.getOriginalSources(); + if (sources && sources.length) { + let parentEditorName = editor.friendlyName; + this._removeStyleSheetEditor(editor); + + for (let source of sources) { + // set so the first sheet will be selected, even if it's a source + source.styleSheetIndex = styleSheet.styleSheetIndex; + source.relatedStyleSheet = styleSheet; + source.relatedEditorName = parentEditorName; + yield 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 + * @return {Promise} that is resolved with the created StyleSheetEditor when + * the editor is fully initialized or rejected on error. + */ + _addStyleSheetEditor: Task.async(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, this._highlighter); + + editor.on("property-change", this._summaryChange.bind(this, editor)); + editor.on("media-rules-changed", this._updateMediaList.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); + + yield editor.fetchSource(); + this._sourceLoaded(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 = (selectedFile) => { + if (!selectedFile) { + // nothing selected + return; + } + NetUtil.asyncFetch({ + uri: NetUtil.newURI(selectedFile), + loadingNode: this._window.document, + securityFlags: Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_DATA_INHERITS, + contentPolicyType: Ci.nsIContentPolicy.TYPE_OTHER + }, (stream, status) => { + if (!Components.isSuccessCode(status)) { + this.emit("error", { key: LOAD_ERROR }); + return; + } + let source = + NetUtil.readInputStreamToString(stream, stream.available()); + stream.close(); + + this._debuggee.addStyleSheet(source).then((styleSheet) => { + this._onStyleSheetCreated(styleSheet, selectedFile); + }); + }); + }; + + 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 {data} data + * The event data + */ + _onError: function (event, data) { + this.emit("error", data); + }, + + /** + * Toggle the original sources pref. + */ + _toggleOrigSources: function () { + let isEnabled = Services.prefs.getBoolPref(PREF_ORIG_SOURCES); + Services.prefs.setBoolPref(PREF_ORIG_SOURCES, !isEnabled); + }, + + /** + * Toggle the pref for showing a @media rules sidebar in each editor. + */ + _toggleMediaSidebar: function () { + let isEnabled = Services.prefs.getBoolPref(PREF_MEDIA_SIDEBAR); + Services.prefs.setBoolPref(PREF_MEDIA_SIDEBAR, !isEnabled); + }, + + /** + * Toggle the @media sidebar in each editor depending on the setting. + */ + _onMediaPrefChanged: function () { + this.editors.forEach(this._updateMediaList); + }, + + /** + * This method handles the following cases related to the context + * menu item "_openLinkNewTabItem": + * + * 1) There was a stylesheet clicked on and it is external: show and + * enable the context menu item + * 2) There was a stylesheet clicked on and it is inline: show and + * disable the context menu item + * 3) There was no stylesheet clicked on (the right click happened + * below the list): hide the context menu + */ + _updateOpenLinkItem: function () { + this._openLinkNewTabItem.setAttribute("hidden", + !this._contextMenuStyleSheet); + if (this._contextMenuStyleSheet) { + this._openLinkNewTabItem.setAttribute("disabled", + !this._contextMenuStyleSheet.href); + } + }, + + /** + * Open a particular stylesheet in a new tab. + */ + _openLinkNewTab: function () { + if (this._contextMenuStyleSheet) { + this._window.openUILinkIn(this._contextMenuStyleSheet.href, "tab"); + } + }, + + /** + * 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) { + let ordinal = editor.styleSheet.styleSheetIndex; + ordinal = ordinal == -1 ? Number.MAX_SAFE_INTEGER : ordinal; + // add new sidebar item and editor to the UI + this._view.appendTemplatedItem(STYLE_EDITOR_TEMPLATE, { + data: { + editor: editor + }, + disableAnimations: this._alwaysDisableAnimations, + ordinal: ordinal, + onCreate: function (summary, details, data) { + let createdEditor = data.editor; + createdEditor.summary = summary; + createdEditor.details = details; + + wire(summary, ".stylesheet-enabled", function onToggleDisabled(event) { + event.stopPropagation(); + event.target.blur(); + + createdEditor.toggleDisabled(); + }); + + wire(summary, ".stylesheet-name", { + events: { + "keypress": (event) => { + if (event.keyCode == KeyCodes.DOM_VK_RETURN) { + this._view.activeSummary = summary; + } + } + } + }); + + wire(summary, ".stylesheet-saveButton", function onSaveButton(event) { + event.stopPropagation(); + event.target.blur(); + + createdEditor.saveToFile(createdEditor.savedFile); + }); + + this._updateSummaryForEditor(createdEditor, summary); + + summary.addEventListener("contextmenu", () => { + this._contextMenuStyleSheet = createdEditor.styleSheet; + }, false); + + summary.addEventListener("focus", function onSummaryFocus(event) { + if (event.target == summary) { + // autofocus the stylesheet name + summary.querySelector(".stylesheet-name").focus(); + } + }, false); + + let sidebar = details.querySelector(".stylesheet-sidebar"); + sidebar.setAttribute("width", + Services.prefs.getIntPref(PREF_SIDEBAR_WIDTH)); + + let splitter = details.querySelector(".devtools-side-splitter"); + splitter.addEventListener("mousemove", () => { + let sidebarWidth = sidebar.getAttribute("width"); + Services.prefs.setIntPref(PREF_SIDEBAR_WIDTH, sidebarWidth); + + // update all @media sidebars for consistency + let sidebars = + [...this._panelDoc.querySelectorAll(".stylesheet-sidebar")]; + for (let mediaSidebar of sidebars) { + mediaSidebar.setAttribute("width", sidebarWidth); + } + }); + + // autofocus if it's a new user-created stylesheet + if (createdEditor.isNew) { + this._selectEditor(createdEditor); + } + + if (this._isEditorToSelect(createdEditor)) { + 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 + && createdEditor.styleSheet.styleSheetIndex == 0) { + this._selectEditor(createdEditor); + } + this.emit("editor-added", createdEditor); + }.bind(this), + + onShow: function (summary, details, data) { + let showEditor = data.editor; + this.selectedEditor = showEditor; + + Task.spawn(function* () { + if (!showEditor.sourceEditor) { + // only initialize source editor when we switch to this view + let inputElement = + details.querySelector(".stylesheet-editor-input"); + yield showEditor.load(inputElement, this._cssProperties); + } + + showEditor.onShow(); + + this.emit("editor-selected", showEditor); + + // Is there any CSS coverage markup to include? + let usage = yield csscoverage.getUsage(this._target); + if (usage == null) { + return; + } + + let sheet = showEditor.styleSheet; + let {reports} = yield usage.createEditorReportForSheet(sheet); + + showEditor.removeAllUnusedRegions(); + + if (reports.length > 0) { + // Only apply if this file isn't compressed. We detect a + // compressed file if there are more rules than lines. + let editorText = showEditor.sourceEditor.getText(); + let lineCount = editorText.split("\n").length; + let ruleCount = showEditor.styleSheet.ruleCount; + if (lineCount >= ruleCount) { + showEditor.addUnusedRegions(reports); + } else { + this.emit("error", { key: "error-compressed", level: "info" }); + } + } + }.bind(this)).then(null, e => console.error(e)); + }.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 toSelect = this._styleSheetToSelect; + + for (let editor of this.editors) { + if (this._isEditorToSelect(editor)) { + // 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, toSelect.line, toSelect.col); + } + } + + return promise.resolve(); + }, + + /** + * Returns whether a given editor is the current editor to be selected. Tests + * based on href or underlying stylesheet. + * + * @param {StyleSheetEditor} editor + * The editor to test. + */ + _isEditorToSelect: function (editor) { + let toSelect = this._styleSheetToSelect; + if (!toSelect) { + return false; + } + let isHref = toSelect.stylesheet === null || + typeof toSelect.stylesheet == "string"; + + return (isHref && editor.styleSheet.href == toSelect.stylesheet) || + (toSelect.stylesheet == editor.styleSheet); + }, + + /** + * 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 and ready + * to be used. + */ + _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 = 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; + }, + + getEditorDetails: function (editor) { + if (editor.details) { + return promise.resolve(editor.details); + } + + let deferred = defer(); + let self = this; + + this.on("editor-added", function onAdd(e, selected) { + if (selected == editor) { + self.off("editor-added", onAdd); + deferred.resolve(editor.details); + } + }); + + return deferred.promise; + }, + + /** + * Returns an identifier for the given style sheet. + * + * @param {StyleSheet} styleSheet + * The style sheet to be identified. + */ + getStyleSheetIdentifier: function (styleSheet) { + // Identify inline style sheets by their host page URI and index + // at the page. + return styleSheet.href ? styleSheet.href : + "inline-" + styleSheet.styleSheetIndex + "-at-" + styleSheet.nodeHref; + }, + + /** + * selects a stylesheet and optionally moves the cursor to a selected line + * + * @param {StyleSheetFront} [stylesheet] + * Stylesheet to select or href of stylesheet to select + * @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). + * @return {Promise} + * Promise that will resolve when the editor is selected and ready + * to be used. + */ + selectStyleSheet: function (stylesheet, line, col) { + this._styleSheetToSelect = { + stylesheet: stylesheet, + 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. */ + return 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) { + ruleCount = editor.styleSheet.relatedStyleSheet.ruleCount; + } + if (ruleCount === undefined) { + ruleCount = "-"; + } + + let 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 linkedCSSSource = ""; + if (editor.linkedCSSFile) { + linkedCSSSource = OS.Path.basename(editor.linkedCSSFile); + } else if (editor.styleSheet.relatedEditorName) { + linkedCSSSource = editor.styleSheet.relatedEditorName; + } + text(summary, ".stylesheet-linked-file", linkedCSSSource); + text(summary, ".stylesheet-title", editor.styleSheet.title || ""); + text(summary, ".stylesheet-rule-count", + PluralForm.get(ruleCount, + getString("ruleCount.label")).replace("#1", ruleCount)); + }, + + /** + * Update the @media rules sidebar for an editor. Hide if there are no rules + * Display a list of the @media rules in the editor's associated style sheet. + * Emits a 'media-list-changed' event after updating the UI. + * + * @param {StyleSheetEditor} editor + * Editor to update @media sidebar of + */ + _updateMediaList: function (editor) { + Task.spawn(function* () { + let details = yield this.getEditorDetails(editor); + let list = details.querySelector(".stylesheet-media-list"); + + while (list.firstChild) { + list.removeChild(list.firstChild); + } + + let rules = editor.mediaRules; + let showSidebar = Services.prefs.getBoolPref(PREF_MEDIA_SIDEBAR); + let sidebar = details.querySelector(".stylesheet-sidebar"); + + let inSource = false; + + for (let rule of rules) { + let {line, column, parentStyleSheet} = rule; + + let location = { + line: line, + column: column, + source: editor.styleSheet.href, + styleSheet: parentStyleSheet + }; + if (editor.styleSheet.isOriginalSource) { + location = yield editor.cssSheet.getOriginalLocation(line, column); + } + + // this @media rule is from a different original source + if (location.source != editor.styleSheet.href) { + continue; + } + inSource = true; + + let div = this._panelDoc.createElement("div"); + div.className = "media-rule-label"; + div.addEventListener("click", + this._jumpToLocation.bind(this, location)); + + let cond = this._panelDoc.createElement("div"); + cond.className = "media-rule-condition"; + if (!rule.matches) { + cond.classList.add("media-condition-unmatched"); + } + if (this._target.tab.tagName == "tab") { + this._setConditionContents(cond, rule.conditionText); + } else { + cond.textContent = rule.conditionText; + } + div.appendChild(cond); + + let link = this._panelDoc.createElement("div"); + link.className = "media-rule-line theme-link"; + if (location.line != -1) { + link.textContent = ":" + location.line; + } + div.appendChild(link); + + list.appendChild(div); + } + + sidebar.hidden = !showSidebar || !inSource; + + this.emit("media-list-changed", editor); + }.bind(this)).then(null, e => console.error(e)); + }, + + /** + * Used to safely inject media query links + * + * @param {HTMLElement} element + * The element corresponding to the media sidebar condition + * @param {String} rawText + * The raw condition text to parse + */ + _setConditionContents(element, rawText) { + const minMaxPattern = /(min\-|max\-)(width|height):\s\d+(px)/ig; + + let match = minMaxPattern.exec(rawText); + let lastParsed = 0; + while (match && match.index != minMaxPattern.lastIndex) { + let matchEnd = match.index + match[0].length; + let node = this._panelDoc.createTextNode( + rawText.substring(lastParsed, match.index) + ); + element.appendChild(node); + + let link = this._panelDoc.createElement("a"); + link.href = "#"; + link.className = "media-responsive-mode-toggle"; + link.textContent = rawText.substring(match.index, matchEnd); + link.addEventListener("click", this._onMediaConditionClick.bind(this)); + element.appendChild(link); + + match = minMaxPattern.exec(rawText); + lastParsed = matchEnd; + } + + let node = this._panelDoc.createTextNode( + rawText.substring(lastParsed, rawText.length) + ); + element.appendChild(node); + }, + + /** + * Called when a media condition is clicked + * If a responsive mode link is clicked, it will launch it. + * + * @param {object} e + * Event object + */ + _onMediaConditionClick: function (e) { + let conditionText = e.target.textContent; + let isWidthCond = conditionText.toLowerCase().indexOf("width") > -1; + let mediaVal = parseInt(/\d+/.exec(conditionText), 10); + + let options = isWidthCond ? {width: mediaVal} : {height: mediaVal}; + this._launchResponsiveMode(options); + e.preventDefault(); + e.stopPropagation(); + }, + + /** + * Launches the responsive mode with a specific width or height + * + * @param {object} options + * Object with width or/and height properties. + */ + _launchResponsiveMode: Task.async(function* (options = {}) { + let tab = this._target.tab; + let win = this._target.tab.ownerDocument.defaultView; + + yield ResponsiveUIManager.openIfNeeded(win, tab); + ResponsiveUIManager.getResponsiveUIForTab(tab).setViewportSize(options); + }), + + /** + * Jump cursor to the editor for a stylesheet and line number for a rule. + * + * @param {object} location + * Location object with 'line', 'column', and 'source' properties. + */ + _jumpToLocation: function (location) { + let source = location.styleSheet || location.source; + this.selectStyleSheet(source, location.line - 1, location.column - 1); + }, + + destroy: function () { + if (this._highlighter) { + this._highlighter.finalize(); + this._highlighter = null; + } + + this._clearStyleSheetEditors(); + + let sidebar = this._panelDoc.querySelector(".splitview-controller"); + let sidebarWidth = sidebar.getAttribute("width"); + Services.prefs.setIntPref(PREF_NAV_WIDTH, sidebarWidth); + + this._optionsMenu.removeEventListener("popupshowing", + this._onOptionsPopupShowing); + this._optionsMenu.removeEventListener("popuphiding", + this._onOptionsPopupHiding); + + this._prefObserver.off(PREF_ORIG_SOURCES, this._onNewDocument); + this._prefObserver.off(PREF_MEDIA_SIDEBAR, this._onMediaPrefChanged); + this._prefObserver.destroy(); + } +}; |