/* 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._seenSheets = new Map(); // Don't add any style sheets that might arrive via events, until // the call to initialize. Style sheets can arrive from the server // at any time, for example if a new style sheet was added, or if // the style sheet actor was just created and is walking the style // sheets for the first time. In any case, in |initialize| we're // going to fetch the list of sheets anyway. this._suppressAdd = true; this._onOptionsPopupShowing = this._onOptionsPopupShowing.bind(this); this._onOptionsPopupHiding = this._onOptionsPopupHiding.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._addStyleSheet = this._addStyleSheet.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._debuggee.on("stylesheet-added", this._addStyleSheet); } 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); }); 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._suppressAdd = true; 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(); this._suppressAdd = false; 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; // Here the keys are style sheet actors, and the values are // promises that resolve to the sheet's editor. See |_addStyleSheet|. this._seenSheets = new Map(); this._suppressAdd = true; 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 * @param {Boolean} isNew * True if this style sheet was created by a call to the * style sheets actor's @see addStyleSheet method. * @return {Promise} * A promise that resolves to the style sheet's editor when the style sheet has * been fully loaded. If the style sheet has a source map, and source mapping * is enabled, then the promise resolves to null. */ _addStyleSheet: function (styleSheet, isNew) { if (this._suppressAdd) { return null; } if (!this._seenSheets.has(styleSheet)) { let promise = (async () => { let editor = await this._addStyleSheetEditor(styleSheet, isNew); if (!Services.prefs.getBoolPref(PREF_ORIG_SOURCES)) { return editor; } let sources = await styleSheet.getOriginalSources(); // A single generated sheet might map to multiple original // sheets, so make editors for each of them. if (sources && sources.length) { let parentEditorName = editor.friendlyName; this._removeStyleSheetEditor(editor); editor = null; 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; await this._addStyleSheetEditor(source); } } return editor; })(); this._seenSheets.set(styleSheet, promise); } return this._seenSheets.get(styleSheet); }, /** * Add a new editor to the UI for a source. * * @param {StyleSheet} styleSheet * Object representing stylesheet * @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, isNew) { // recall location of saved file for this sheet after page reload let file = null; let identifier = this.getStyleSheetIdentifier(styleSheet); let savedFile = this.savedLocations[identifier]; if (savedFile) { 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._suppressAdd = true; this._debuggee.addStyleSheet(source).then((styleSheet) => { this._suppressAdd = false; this._addStyleSheet(styleSheet, true).then(editor => { if (editor) { editor.savedFile = selectedFile; } // Just for testing purposes. this.emit("test:editor-updated", editor); }); }); }); }; showFilePicker(file, false, parentWindow, onFileSelected); }, /** * 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(); this._seenSheets = null; this._suppressAdd = false; 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(); this._debuggee.off("stylesheet-added", this._addStyleSheet); } };