summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/style-sheet.js
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--devtools/server/actors/style-sheet.js568
1 files changed, 568 insertions, 0 deletions
diff --git a/devtools/server/actors/style-sheet.js b/devtools/server/actors/style-sheet.js
new file mode 100644
index 0000000000..2f155c4323
--- /dev/null
+++ b/devtools/server/actors/style-sheet.js
@@ -0,0 +1,568 @@
+/* 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 protocol = require("resource://devtools/shared/protocol.js");
+const {
+ LongStringActor,
+} = require("resource://devtools/server/actors/string.js");
+const {
+ MediaRuleActor,
+} = require("resource://devtools/server/actors/media-rule.js");
+const { fetch } = require("resource://devtools/shared/DevToolsUtils.js");
+const {
+ styleSheetSpec,
+} = require("resource://devtools/shared/specs/style-sheet.js");
+const InspectorUtils = require("InspectorUtils");
+const {
+ getSourcemapBaseURL,
+} = require("resource://devtools/server/actors/utils/source-map-utils.js");
+
+loader.lazyRequireGetter(
+ this,
+ "CssLogic",
+ "resource://devtools/shared/inspector/css-logic.js"
+);
+loader.lazyRequireGetter(
+ this,
+ ["addPseudoClassLock", "removePseudoClassLock"],
+ "resource://devtools/server/actors/highlighters/utils/markup.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "loadSheet",
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+
+var TRANSITION_PSEUDO_CLASS = ":-moz-styleeditor-transitioning";
+var TRANSITION_DURATION_MS = 500;
+var TRANSITION_BUFFER_MS = 1000;
+var TRANSITION_RULE_SELECTOR = `:root${TRANSITION_PSEUDO_CLASS}, :root${TRANSITION_PSEUDO_CLASS} *`;
+
+var TRANSITION_SHEET =
+ "data:text/css;charset=utf-8," +
+ encodeURIComponent(`
+ ${TRANSITION_RULE_SELECTOR} {
+ transition-duration: ${TRANSITION_DURATION_MS}ms !important;
+ transition-delay: 0ms !important;
+ transition-timing-function: ease-out !important;
+ transition-property: all !important;
+ }
+`);
+
+// The possible kinds of style-applied events.
+// UPDATE_PRESERVING_RULES means that the update is guaranteed to
+// preserve the number and order of rules on the style sheet.
+// UPDATE_GENERAL covers any other kind of change to the style sheet.
+const UPDATE_PRESERVING_RULES = 0;
+exports.UPDATE_PRESERVING_RULES = UPDATE_PRESERVING_RULES;
+const UPDATE_GENERAL = 1;
+exports.UPDATE_GENERAL = UPDATE_GENERAL;
+
+// If the user edits a style sheet, we stash a copy of the edited text
+// here, keyed by the style sheet. This way, if the tools are closed
+// and then reopened, the edited text will be available. A weak map
+// is used so that navigation by the user will eventually cause the
+// edited text to be collected.
+const modifiedStyleSheets = new WeakMap();
+
+function getSheetText(sheet) {
+ const cssText = modifiedStyleSheets.get(sheet);
+ if (cssText !== undefined) {
+ return Promise.resolve(cssText);
+ }
+
+ if (!sheet.href) {
+ // this is an inline <style> sheet
+ const content = sheet.ownerNode.textContent;
+ return Promise.resolve(content);
+ }
+
+ return fetchStylesheet(sheet).then(({ content }) => content);
+}
+
+exports.getSheetText = getSheetText;
+
+/**
+ * For imported stylesheets, `ownerNode` is null.
+ * To resolve the ownerNode for an imported stylesheet, loop on `parentStylesheet`
+ * until we reach the topmost stylesheet, which should have a valid ownerNode.
+ *
+ * @param {StyleSheet}
+ * The stylesheet for which we want to retrieve the ownerNode.
+ * @return {DOMNode} The ownerNode
+ */
+function getSheetOwnerNode(sheet) {
+ // If this is not an imported stylesheet and we have an ownerNode available
+ // bail out immediately.
+ if (sheet.ownerNode) {
+ return sheet.ownerNode;
+ }
+
+ let parentStyleSheet = sheet;
+ while (
+ parentStyleSheet.parentStyleSheet &&
+ parentStyleSheet !== parentStyleSheet.parentStyleSheet
+ ) {
+ parentStyleSheet = parentStyleSheet.parentStyleSheet;
+ }
+
+ return parentStyleSheet.ownerNode;
+}
+exports.getSheetOwnerNode = getSheetOwnerNode;
+
+/**
+ * Get the charset of the stylesheet.
+ */
+function getCSSCharset(sheet) {
+ if (sheet) {
+ // charset attribute of <link> or <style> element, if it exists
+ if (sheet.ownerNode?.getAttribute) {
+ const linkCharset = sheet.ownerNode.getAttribute("charset");
+ if (linkCharset != null) {
+ return linkCharset;
+ }
+ }
+
+ // charset of referring document.
+ if (sheet.ownerNode?.ownerDocument.characterSet) {
+ return sheet.ownerNode.ownerDocument.characterSet;
+ }
+ }
+
+ return "UTF-8";
+}
+
+/**
+ * Fetch a stylesheet at the provided URL. Returns a promise that will resolve the
+ * result of the fetch command.
+ *
+ * @return {Promise} a promise that resolves with an object with the following members
+ * on success:
+ * - content: the document at that URL, as a string,
+ * - contentType: the content type of the document
+ * If an error occurs, the promise is rejected with that error.
+ */
+async function fetchStylesheet(sheet) {
+ const href = sheet.href;
+
+ const options = {
+ loadFromCache: true,
+ policy: Ci.nsIContentPolicy.TYPE_INTERNAL_STYLESHEET,
+ charset: getCSSCharset(sheet),
+ };
+
+ // Bug 1282660 - We use the system principal to load the default internal
+ // stylesheets instead of the content principal since such stylesheets
+ // require system principal to load. At meanwhile, we strip the loadGroup
+ // for preventing the assertion of the userContextId mismatching.
+
+ // chrome|file|resource|moz-extension protocols rely on the system principal.
+ const excludedProtocolsRe = /^(chrome|file|resource|moz-extension):\/\//;
+ if (!excludedProtocolsRe.test(href)) {
+ // Stylesheets using other protocols should use the content principal.
+ const ownerNode = getSheetOwnerNode(sheet);
+ if (ownerNode) {
+ // eslint-disable-next-line mozilla/use-ownerGlobal
+ options.window = ownerNode.ownerDocument.defaultView;
+ options.principal = ownerNode.ownerDocument.nodePrincipal;
+ }
+ }
+
+ let result;
+
+ try {
+ result = await fetch(href, options);
+ } catch (e) {
+ // The list of excluded protocols can be missing some protocols, try to use the
+ // system principal if the first fetch failed.
+ console.error(
+ `stylesheets actor: fetch failed for ${href},` +
+ ` using system principal instead.`
+ );
+ options.window = undefined;
+ options.principal = undefined;
+ result = await fetch(href, options);
+ }
+
+ return result;
+}
+
+/**
+ * A StyleSheetActor represents a stylesheet on the server.
+ */
+var StyleSheetActor = protocol.ActorClassWithSpec(styleSheetSpec, {
+ toString() {
+ return "[StyleSheetActor " + this.actorID + "]";
+ },
+
+ /**
+ * Window of target
+ */
+ get window() {
+ return this.parentActor.window;
+ },
+
+ /**
+ * Document of target.
+ */
+ get document() {
+ return this.window.document;
+ },
+
+ /**
+ * StyleSheet's window.
+ */
+ get ownerWindow() {
+ // eslint-disable-next-line mozilla/use-ownerGlobal
+ return this.ownerDocument.defaultView;
+ },
+
+ get ownerNode() {
+ return this.rawSheet.ownerNode;
+ },
+
+ /**
+ * URL of underlying stylesheet.
+ */
+ get href() {
+ return this.rawSheet.href;
+ },
+
+ /**
+ * Returns the stylesheet href or the document href if the sheet is inline.
+ */
+ get safeHref() {
+ let href = this.href;
+ if (!href) {
+ if (this.ownerNode.nodeType == this.ownerNode.DOCUMENT_NODE) {
+ href = this.ownerNode.location.href;
+ } else if (
+ this.ownerNode.ownerDocument &&
+ this.ownerNode.ownerDocument.location
+ ) {
+ href = this.ownerNode.ownerDocument.location.href;
+ }
+ }
+ return href;
+ },
+
+ /**
+ * Retrieve the index (order) of stylesheet in the document.
+ *
+ * @return number
+ */
+ get styleSheetIndex() {
+ if (this._styleSheetIndex == -1) {
+ const styleSheets = InspectorUtils.getAllStyleSheets(this.document, true);
+ for (let i = 0; i < styleSheets.length; i++) {
+ if (styleSheets[i] == this.rawSheet) {
+ this._styleSheetIndex = i;
+ break;
+ }
+ }
+ }
+ return this._styleSheetIndex;
+ },
+
+ destroy() {
+ if (this._transitionTimeout && this.window) {
+ this.window.clearTimeout(this._transitionTimeout);
+ removePseudoClassLock(
+ this.document.documentElement,
+ TRANSITION_PSEUDO_CLASS
+ );
+ }
+ protocol.Actor.prototype.destroy.call(this);
+ },
+
+ initialize(styleSheet, parentActor) {
+ protocol.Actor.prototype.initialize.call(this, parentActor.conn);
+
+ this.rawSheet = styleSheet;
+ this.parentActor = parentActor;
+ this.conn = this.parentActor.conn;
+
+ // text and index are unknown until source load
+ this.text = null;
+ this._styleSheetIndex = -1;
+
+ // When the style is imported, `styleSheet.ownerNode` is null,
+ // so retrieve the topmost parent style sheet which has an ownerNode
+ let parentStyleSheet = styleSheet;
+ while (parentStyleSheet.parentStyleSheet) {
+ parentStyleSheet = parentStyleSheet.parentStyleSheet;
+ }
+ // When the style is injected via nsIDOMWindowUtils.loadSheet, even
+ // the parent style sheet has no owner, so default back to target actor
+ // document
+ if (parentStyleSheet.ownerNode) {
+ this.ownerDocument = parentStyleSheet.ownerNode.ownerDocument;
+ } else {
+ this.ownerDocument = parentActor.window;
+ }
+ },
+
+ /**
+ * Test whether this sheet has been modified by CSSOM.
+ * @return {Boolean} true if changed by CSSOM.
+ */
+ hasRulesModifiedByCSSOM() {
+ return InspectorUtils.hasRulesModifiedByCSSOM(this.rawSheet);
+ },
+
+ /**
+ * Get the raw stylesheet's cssRules once the sheet has been loaded.
+ *
+ * @return {Promise}
+ * Promise that resolves with a CSSRuleList
+ */
+ getCSSRules() {
+ let rules;
+ try {
+ rules = this.rawSheet.cssRules;
+ } catch (e) {
+ // sheet isn't loaded yet
+ }
+
+ if (rules) {
+ return Promise.resolve(rules);
+ }
+
+ if (!this.ownerNode) {
+ return Promise.resolve([]);
+ }
+
+ if (this._cssRules) {
+ return this._cssRules;
+ }
+
+ // cache so we don't add many listeners if this is called multiple times.
+ this._cssRules = new Promise(resolve => {
+ const onSheetLoaded = event => {
+ this.ownerNode.removeEventListener("load", onSheetLoaded);
+
+ resolve(this.rawSheet.cssRules);
+ };
+
+ this.ownerNode.addEventListener("load", onSheetLoaded);
+ });
+
+ return this._cssRules;
+ },
+
+ /**
+ * Get the current state of the actor
+ *
+ * @return {object}
+ * With properties of the underlying stylesheet, plus 'text',
+ * 'styleSheetIndex' and 'parentActor' if it's @imported
+ */
+ form() {
+ let docHref;
+ if (this.ownerNode) {
+ if (this.ownerNode.nodeType == this.ownerNode.DOCUMENT_NODE) {
+ docHref = this.ownerNode.location.href;
+ } else if (
+ this.ownerNode.ownerDocument &&
+ this.ownerNode.ownerDocument.location
+ ) {
+ docHref = this.ownerNode.ownerDocument.location.href;
+ }
+ }
+
+ const form = {
+ actor: this.actorID, // actorID is set when this actor is added to a pool
+ href: this.href,
+ nodeHref: docHref,
+ disabled: this.rawSheet.disabled,
+ constructed: this.rawSheet.constructed,
+ title: this.rawSheet.title,
+ system: CssLogic.isAgentStylesheet(this.rawSheet),
+ styleSheetIndex: this.styleSheetIndex,
+ sourceMapBaseURL: getSourcemapBaseURL(
+ // Technically resolveSourceURL should be used here alongside
+ // "this.rawSheet.sourceURL", but the style inspector does not support
+ // /*# sourceURL=*/ in CSS, so we're omitting it here (bug 880831).
+ this.href || docHref,
+ this.ownerWindow
+ ),
+ sourceMapURL: this.rawSheet.sourceMapURL,
+ };
+
+ try {
+ form.ruleCount = this.rawSheet.cssRules.length;
+ } catch (e) {
+ // stylesheet had an @import rule that wasn't loaded yet
+ this.getCSSRules().then(() => {
+ this._notifyPropertyChanged("ruleCount");
+ });
+ }
+ return form;
+ },
+
+ /**
+ * Toggle the disabled property of the style sheet
+ *
+ * @return {object}
+ * 'disabled' - the disabled state after toggling.
+ */
+ toggleDisabled() {
+ this.rawSheet.disabled = !this.rawSheet.disabled;
+ this._notifyPropertyChanged("disabled");
+
+ return this.rawSheet.disabled;
+ },
+
+ /**
+ * Send an event notifying that a property of the stylesheet
+ * has changed.
+ *
+ * @param {string} property
+ * Name of the changed property
+ */
+ _notifyPropertyChanged(property) {
+ this.emit("property-change", property, this.form()[property]);
+ },
+
+ /**
+ * Protocol method to get the text of this stylesheet.
+ */
+ getText() {
+ return this._getText().then(text => {
+ return new LongStringActor(this.conn, text || "");
+ });
+ },
+
+ /**
+ * Fetch the text for this stylesheet from the cache or network. Return
+ * cached text if it's already been fetched.
+ *
+ * @return {Promise}
+ * Promise that resolves with a string text of the stylesheet.
+ */
+ _getText() {
+ if (typeof this.text === "string") {
+ return Promise.resolve(this.text);
+ }
+
+ return getSheetText(this.rawSheet).then(text => {
+ this.text = text;
+ return text;
+ });
+ },
+
+ /**
+ * Protocol method to get the media rules for the stylesheet.
+ */
+ getMediaRules() {
+ return this._getMediaRules();
+ },
+
+ /**
+ * Get all the @media rules in this stylesheet.
+ *
+ * @return {promise}
+ * A promise that resolves with an array of MediaRuleActors.
+ */
+ _getMediaRules() {
+ const mediaRules = [];
+ const traverseRules = ruleList => {
+ for (const rule of ruleList) {
+ if (rule.type === CSSRule.MEDIA_RULE) {
+ const actor = new MediaRuleActor(rule, this);
+ this.manage(actor);
+ mediaRules.push(actor);
+ }
+
+ if (rule.cssRules) {
+ traverseRules(rule.cssRules);
+ }
+ }
+ };
+
+ return this.getCSSRules().then(rules => {
+ traverseRules(rules);
+ return mediaRules;
+ });
+ },
+
+ /**
+ * Update the style sheet in place with new text.
+ *
+ * @param {string} text: new text
+ * @param {boolean} transition: whether to do CSS transition for change.
+ * @param {string} kind: either UPDATE_PRESERVING_RULES or UPDATE_GENERAL
+ * @param {string|null} cause: indicates the cause of this update
+ */
+ update(text, transition, kind = UPDATE_GENERAL, cause) {
+ InspectorUtils.parseStyleSheet(this.rawSheet, text);
+
+ modifiedStyleSheets.set(this.rawSheet, text);
+
+ this.text = text;
+
+ if (kind != UPDATE_PRESERVING_RULES) {
+ this._notifyPropertyChanged("ruleCount");
+ }
+
+ if (transition) {
+ this._startTransition(kind, cause);
+ } else {
+ this.emit("style-applied", kind, this, cause);
+ }
+
+ this._getMediaRules().then(rules => {
+ this.emit("media-rules-changed", rules);
+ });
+ },
+
+ /**
+ * Insert a catch-all transition sheet into the document. Set a timeout
+ * to remove the transition after a certain time.
+ *
+ * @param {string} kind: either UPDATE_PRESERVING_RULES or UPDATE_GENERAL
+ * @param {string|null} cause: indicates the cause of this update
+ */
+ _startTransition(kind, cause) {
+ if (!this._transitionSheetLoaded) {
+ this._transitionSheetLoaded = true;
+ // We don't remove this sheet. It uses an internal selector that
+ // we only apply via locks, so there's no need to load and unload
+ // it all the time.
+ loadSheet(this.window, TRANSITION_SHEET);
+ }
+
+ addPseudoClassLock(this.document.documentElement, TRANSITION_PSEUDO_CLASS);
+
+ // Set up clean up and commit after transition duration (+buffer)
+ // @see _onTransitionEnd
+ this.window.clearTimeout(this._transitionTimeout);
+ this._transitionTimeout = this.window.setTimeout(
+ this._onTransitionEnd.bind(this, kind, cause),
+ TRANSITION_DURATION_MS + TRANSITION_BUFFER_MS
+ );
+ },
+
+ /**
+ * This cleans up class and rule added for transition effect and then
+ * notifies that the style has been applied.
+ *
+ * @param {string} kind: either UPDATE_PRESERVING_RULES or UPDATE_GENERAL
+ * @param {string|null} cause: indicates the cause of this update
+ */
+ _onTransitionEnd(kind, cause) {
+ this._transitionTimeout = null;
+ removePseudoClassLock(
+ this.document.documentElement,
+ TRANSITION_PSEUDO_CLASS
+ );
+ this.emit("style-applied", kind, this, cause);
+ },
+});
+
+exports.StyleSheetActor = StyleSheetActor;