diff options
Diffstat (limited to 'devtools/server/actors/styles.js')
-rw-r--r-- | devtools/server/actors/styles.js | 1687 |
1 files changed, 1687 insertions, 0 deletions
diff --git a/devtools/server/actors/styles.js b/devtools/server/actors/styles.js new file mode 100644 index 000000000..cdb812882 --- /dev/null +++ b/devtools/server/actors/styles.js @@ -0,0 +1,1687 @@ +/* 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 {Cc, Ci} = require("chrome"); +const promise = require("promise"); +const protocol = require("devtools/shared/protocol"); +const {LongStringActor} = require("devtools/server/actors/string"); +const {getDefinedGeometryProperties} = require("devtools/server/actors/highlighters/geometry-editor"); +const {parseDeclarations} = require("devtools/shared/css/parsing-utils"); +const {isCssPropertyKnown} = require("devtools/server/actors/css-properties"); +const {Task} = require("devtools/shared/task"); +const events = require("sdk/event/core"); + +// This will also add the "stylesheet" actor type for protocol.js to recognize +const {UPDATE_PRESERVING_RULES, UPDATE_GENERAL} = require("devtools/server/actors/stylesheets"); +const {pageStyleSpec, styleRuleSpec, ELEMENT_STYLE} = require("devtools/shared/specs/styles"); + +loader.lazyGetter(this, "CssLogic", () => require("devtools/server/css-logic").CssLogic); +loader.lazyGetter(this, "SharedCssLogic", () => require("devtools/shared/inspector/css-logic")); +loader.lazyGetter(this, "DOMUtils", () => Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils)); + +loader.lazyGetter(this, "PSEUDO_ELEMENTS", () => { + return DOMUtils.getCSSPseudoElementNames(); +}); + +const XHTML_NS = "http://www.w3.org/1999/xhtml"; +const FONT_PREVIEW_TEXT = "Abc"; +const FONT_PREVIEW_FONT_SIZE = 40; +const FONT_PREVIEW_FILLSTYLE = "black"; +const NORMAL_FONT_WEIGHT = 400; +const BOLD_FONT_WEIGHT = 700; +// Offset (in px) to avoid cutting off text edges of italic fonts. +const FONT_PREVIEW_OFFSET = 4; + +/** + * The PageStyle actor lets the client look at the styles on a page, as + * they are applied to a given node. + */ +var PageStyleActor = protocol.ActorClassWithSpec(pageStyleSpec, { + /** + * Create a PageStyleActor. + * + * @param inspector + * The InspectorActor that owns this PageStyleActor. + * + * @constructor + */ + initialize: function (inspector) { + protocol.Actor.prototype.initialize.call(this, null); + this.inspector = inspector; + if (!this.inspector.walker) { + throw Error("The inspector's WalkerActor must be created before " + + "creating a PageStyleActor."); + } + this.walker = inspector.walker; + this.cssLogic = new CssLogic(DOMUtils.isInheritedProperty); + + // Stores the association of DOM objects -> actors + this.refMap = new Map(); + + // Maps document elements to style elements, used to add new rules. + this.styleElements = new WeakMap(); + + this.onFrameUnload = this.onFrameUnload.bind(this); + this.onStyleSheetAdded = this.onStyleSheetAdded.bind(this); + + events.on(this.inspector.tabActor, "will-navigate", this.onFrameUnload); + events.on(this.inspector.tabActor, "stylesheet-added", this.onStyleSheetAdded); + + this._styleApplied = this._styleApplied.bind(this); + this._watchedSheets = new Set(); + }, + + destroy: function () { + if (!this.walker) { + return; + } + protocol.Actor.prototype.destroy.call(this); + events.off(this.inspector.tabActor, "will-navigate", this.onFrameUnload); + events.off(this.inspector.tabActor, "stylesheet-added", this.onStyleSheetAdded); + this.inspector = null; + this.walker = null; + this.refMap = null; + this.cssLogic = null; + this.styleElements = null; + + for (let sheet of this._watchedSheets) { + sheet.off("style-applied", this._styleApplied); + } + this._watchedSheets.clear(); + }, + + get conn() { + return this.inspector.conn; + }, + + form: function (detail) { + if (detail === "actorid") { + return this.actorID; + } + + return { + actor: this.actorID, + traits: { + // Whether the actor has had bug 1103993 fixed, which means that the + // getApplied method calls cssLogic.highlight(node) to recreate the + // style cache. Clients requesting getApplied from actors that have not + // been fixed must make sure cssLogic.highlight(node) was called before. + getAppliedCreatesStyleCache: true, + // Whether addNewRule accepts the editAuthored argument. + authoredStyles: true + } + }; + }, + + /** + * Called when a style sheet is updated. + */ + _styleApplied: function (kind, styleSheet) { + // No matter what kind of update is done, we need to invalidate + // the keyframe cache. + this.cssLogic.reset(); + if (kind === UPDATE_GENERAL) { + events.emit(this, "stylesheet-updated", styleSheet); + } + }, + + /** + * Return or create a StyleRuleActor for the given item. + * @param item Either a CSSStyleRule or a DOM element. + */ + _styleRef: function (item) { + if (this.refMap.has(item)) { + return this.refMap.get(item); + } + let actor = StyleRuleActor(this, item); + this.manage(actor); + this.refMap.set(item, actor); + + return actor; + }, + + /** + * Update the association between a StyleRuleActor and its + * corresponding item. This is used when a StyleRuleActor updates + * as style sheet and starts using a new rule. + * + * @param oldItem The old association; either a CSSStyleRule or a + * DOM element. + * @param item Either a CSSStyleRule or a DOM element. + * @param actor a StyleRuleActor + */ + updateStyleRef: function (oldItem, item, actor) { + this.refMap.delete(oldItem); + this.refMap.set(item, actor); + }, + + /** + * Return or create a StyleSheetActor for the given nsIDOMCSSStyleSheet. + * @param {DOMStyleSheet} sheet + * The style sheet to create an actor for. + * @return {StyleSheetActor} + * The actor for this style sheet + */ + _sheetRef: function (sheet) { + let tabActor = this.inspector.tabActor; + let actor = tabActor.createStyleSheetActor(sheet); + return actor; + }, + + /** + * Get the computed style for a node. + * + * @param NodeActor node + * @param object options + * `filter`: A string filter that affects the "matched" handling. + * 'user': Include properties from user style sheets. + * 'ua': Include properties from user and user-agent sheets. + * Default value is 'ua' + * `markMatched`: true if you want the 'matched' property to be added + * when a computed property has been modified by a style included + * by `filter`. + * `onlyMatched`: true if unmatched properties shouldn't be included. + * + * @returns a JSON blob with the following form: + * { + * "property-name": { + * value: "property-value", + * priority: "!important" <optional> + * matched: <true if there are matched selectors for this value> + * }, + * ... + * } + */ + getComputed: function (node, options) { + let ret = Object.create(null); + + this.cssLogic.sourceFilter = options.filter || SharedCssLogic.FILTER.UA; + this.cssLogic.highlight(node.rawNode); + let computed = this.cssLogic.computedStyle || []; + + Array.prototype.forEach.call(computed, name => { + ret[name] = { + value: computed.getPropertyValue(name), + priority: computed.getPropertyPriority(name) || undefined + }; + }); + + if (options.markMatched || options.onlyMatched) { + let matched = this.cssLogic.hasMatchedSelectors(Object.keys(ret)); + for (let key in ret) { + if (matched[key]) { + ret[key].matched = options.markMatched ? true : undefined; + } else if (options.onlyMatched) { + delete ret[key]; + } + } + } + + return ret; + }, + + /** + * Get all the fonts from a page. + * + * @param object options + * `includePreviews`: Whether to also return image previews of the fonts. + * `previewText`: The text to display in the previews. + * `previewFontSize`: The font size of the text in the previews. + * + * @returns object + * object with 'fontFaces', a list of fonts that apply to this node. + */ + getAllUsedFontFaces: function (options) { + let windows = this.inspector.tabActor.windows; + let fontsList = []; + for (let win of windows) { + fontsList = [...fontsList, + ...this.getUsedFontFaces(win.document.body, options)]; + } + return fontsList; + }, + + /** + * Get the font faces used in an element. + * + * @param NodeActor node / actual DOM node + * The node to get fonts from. + * @param object options + * `includePreviews`: Whether to also return image previews of the fonts. + * `previewText`: The text to display in the previews. + * `previewFontSize`: The font size of the text in the previews. + * + * @returns object + * object with 'fontFaces', a list of fonts that apply to this node. + */ + getUsedFontFaces: function (node, options) { + // node.rawNode is defined for NodeActor objects + let actualNode = node.rawNode || node; + let contentDocument = actualNode.ownerDocument; + // We don't get fonts for a node, but for a range + let rng = contentDocument.createRange(); + rng.selectNodeContents(actualNode); + let fonts = DOMUtils.getUsedFontFaces(rng); + let fontsArray = []; + + for (let i = 0; i < fonts.length; i++) { + let font = fonts.item(i); + let fontFace = { + name: font.name, + CSSFamilyName: font.CSSFamilyName, + srcIndex: font.srcIndex, + URI: font.URI, + format: font.format, + localName: font.localName, + metadata: font.metadata + }; + + // If this font comes from a @font-face rule + if (font.rule) { + let styleActor = StyleRuleActor(this, font.rule); + this.manage(styleActor); + fontFace.rule = styleActor; + fontFace.ruleText = font.rule.cssText; + } + + // Get the weight and style of this font for the preview and sort order + let weight = NORMAL_FONT_WEIGHT, style = ""; + if (font.rule) { + weight = font.rule.style.getPropertyValue("font-weight") + || NORMAL_FONT_WEIGHT; + if (weight == "bold") { + weight = BOLD_FONT_WEIGHT; + } else if (weight == "normal") { + weight = NORMAL_FONT_WEIGHT; + } + style = font.rule.style.getPropertyValue("font-style") || ""; + } + fontFace.weight = weight; + fontFace.style = style; + + if (options.includePreviews) { + let opts = { + previewText: options.previewText, + previewFontSize: options.previewFontSize, + fontStyle: weight + " " + style, + fillStyle: options.previewFillStyle + }; + let { dataURL, size } = getFontPreviewData(font.CSSFamilyName, + contentDocument, opts); + fontFace.preview = { + data: LongStringActor(this.conn, dataURL), + size: size + }; + } + fontsArray.push(fontFace); + } + + // @font-face fonts at the top, then alphabetically, then by weight + fontsArray.sort(function (a, b) { + return a.weight > b.weight ? 1 : -1; + }); + fontsArray.sort(function (a, b) { + if (a.CSSFamilyName == b.CSSFamilyName) { + return 0; + } + return a.CSSFamilyName > b.CSSFamilyName ? 1 : -1; + }); + fontsArray.sort(function (a, b) { + if ((a.rule && b.rule) || (!a.rule && !b.rule)) { + return 0; + } + return !a.rule && b.rule ? 1 : -1; + }); + + return fontsArray; + }, + + /** + * Get a list of selectors that match a given property for a node. + * + * @param NodeActor node + * @param string property + * @param object options + * `filter`: A string filter that affects the "matched" handling. + * 'user': Include properties from user style sheets. + * 'ua': Include properties from user and user-agent sheets. + * Default value is 'ua' + * + * @returns a JSON object with the following form: + * { + * // An ordered list of rules that apply + * matched: [{ + * rule: <rule actorid>, + * sourceText: <string>, // The source of the selector, relative + * // to the node in question. + * selector: <string>, // the selector ID that matched + * value: <string>, // the value of the property + * status: <int>, + * // The status of the match - high numbers are better placed + * // to provide styling information: + * // 3: Best match, was used. + * // 2: Matched, but was overridden. + * // 1: Rule from a parent matched. + * // 0: Unmatched (never returned in this API) + * }, ...], + * + * // The full form of any domrule referenced. + * rules: [ <domrule>, ... ], // The full form of any domrule referenced + * + * // The full form of any sheets referenced. + * sheets: [ <domsheet>, ... ] + * } + */ + getMatchedSelectors: function (node, property, options) { + this.cssLogic.sourceFilter = options.filter || SharedCssLogic.FILTER.UA; + this.cssLogic.highlight(node.rawNode); + + let rules = new Set(); + let sheets = new Set(); + + let matched = []; + let propInfo = this.cssLogic.getPropertyInfo(property); + for (let selectorInfo of propInfo.matchedSelectors) { + let cssRule = selectorInfo.selector.cssRule; + let domRule = cssRule.sourceElement || cssRule.domRule; + + let rule = this._styleRef(domRule); + rules.add(rule); + + matched.push({ + rule: rule, + sourceText: this.getSelectorSource(selectorInfo, node.rawNode), + selector: selectorInfo.selector.text, + name: selectorInfo.property, + value: selectorInfo.value, + status: selectorInfo.status + }); + } + + this.expandSets(rules, sheets); + + return { + matched: matched, + rules: [...rules], + sheets: [...sheets] + }; + }, + + // Get a selector source for a CssSelectorInfo relative to a given + // node. + getSelectorSource: function (selectorInfo, relativeTo) { + let result = selectorInfo.selector.text; + if (selectorInfo.elementStyle) { + let source = selectorInfo.sourceElement; + if (source === relativeTo) { + result = "this"; + } else { + result = CssLogic.getShortName(source); + } + result += ".style"; + } + return result; + }, + + /** + * Get the set of styles that apply to a given node. + * @param NodeActor node + * @param object options + * `filter`: A string filter that affects the "matched" handling. + * 'user': Include properties from user style sheets. + * 'ua': Include properties from user and user-agent sheets. + * Default value is 'ua' + * `inherited`: Include styles inherited from parent nodes. + * `matchedSelectors`: Include an array of specific selectors that + * caused this rule to match its node. + */ + getApplied: Task.async(function* (node, options) { + if (!node) { + return {entries: [], rules: [], sheets: []}; + } + + this.cssLogic.highlight(node.rawNode); + let entries = []; + entries = entries.concat(this._getAllElementRules(node, undefined, + options)); + + let result = this.getAppliedProps(node, entries, options); + for (let rule of result.rules) { + // See the comment in |form| to understand this. + yield rule.getAuthoredCssText(); + } + return result; + }), + + _hasInheritedProps: function (style) { + return Array.prototype.some.call(style, prop => { + return DOMUtils.isInheritedProperty(prop); + }); + }, + + isPositionEditable: Task.async(function* (node) { + if (!node || node.rawNode.nodeType !== node.rawNode.ELEMENT_NODE) { + return false; + } + + let props = getDefinedGeometryProperties(node.rawNode); + + // Elements with only `width` and `height` are currently not considered + // editable. + return props.has("top") || + props.has("right") || + props.has("left") || + props.has("bottom"); + }), + + /** + * Helper function for getApplied, gets all the rules from a given + * element. See getApplied for documentation on parameters. + * @param NodeActor node + * @param bool inherited + * @param object options + + * @return Array The rules for a given element. Each item in the + * array has the following signature: + * - rule RuleActor + * - isSystem Boolean + * - inherited Boolean + * - pseudoElement String + */ + _getAllElementRules: function (node, inherited, options) { + let {bindingElement, pseudo} = + CssLogic.getBindingElementAndPseudo(node.rawNode); + let rules = []; + + if (!bindingElement || !bindingElement.style) { + return rules; + } + + let elementStyle = this._styleRef(bindingElement); + let showElementStyles = !inherited && !pseudo; + let showInheritedStyles = inherited && + this._hasInheritedProps(bindingElement.style); + + let rule = { + rule: elementStyle, + pseudoElement: null, + isSystem: false, + inherited: false + }; + + // First any inline styles + if (showElementStyles) { + rules.push(rule); + } + + // Now any inherited styles + if (showInheritedStyles) { + rule.inherited = inherited; + rules.push(rule); + } + + // Add normal rules. Typically this is passing in the node passed into the + // function, unless if that node was ::before/::after. In which case, + // it will pass in the parentNode along with "::before"/"::after". + this._getElementRules(bindingElement, pseudo, inherited, options) + .forEach(oneRule => { + // The only case when there would be a pseudo here is + // ::before/::after, and in this case we want to tell the + // view that it belongs to the element (which is a + // _moz_generated_content native anonymous element). + oneRule.pseudoElement = null; + rules.push(oneRule); + }); + + // Now any pseudos. + if (showElementStyles) { + for (let readPseudo of PSEUDO_ELEMENTS) { + this._getElementRules(bindingElement, readPseudo, inherited, options) + .forEach(oneRule => { + rules.push(oneRule); + }); + } + } + + return rules; + }, + + /** + * Helper function for _getAllElementRules, returns the rules from a given + * element. See getApplied for documentation on parameters. + * @param DOMNode node + * @param string pseudo + * @param DOMNode inherited + * @param object options + * + * @returns Array + */ + _getElementRules: function (node, pseudo, inherited, options) { + let domRules = DOMUtils.getCSSStyleRules(node, pseudo); + if (!domRules) { + return []; + } + + let rules = []; + + // getCSSStyleRules returns ordered from least-specific to + // most-specific. + for (let i = domRules.Count() - 1; i >= 0; i--) { + let domRule = domRules.GetElementAt(i); + + let isSystem = !SharedCssLogic.isContentStylesheet(domRule.parentStyleSheet); + + if (isSystem && options.filter != SharedCssLogic.FILTER.UA) { + continue; + } + + if (inherited) { + // Don't include inherited rules if none of its properties + // are inheritable. + let hasInherited = [...domRule.style].some( + prop => DOMUtils.isInheritedProperty(prop) + ); + if (!hasInherited) { + continue; + } + } + + let ruleActor = this._styleRef(domRule); + rules.push({ + rule: ruleActor, + inherited: inherited, + isSystem: isSystem, + pseudoElement: pseudo + }); + } + return rules; + }, + + /** + * Given a node and a CSS rule, walk up the DOM looking for a + * matching element rule. Return an array of all found entries, in + * the form generated by _getAllElementRules. Note that this will + * always return an array of either zero or one element. + * + * @param {NodeActor} node the node + * @param {CSSStyleRule} filterRule the rule to filter for + * @return {Array} array of zero or one elements; if one, the element + * is the entry as returned by _getAllElementRules. + */ + findEntryMatchingRule: function (node, filterRule) { + const options = {matchedSelectors: true, inherited: true}; + let entries = []; + let parent = this.walker.parentNode(node); + while (parent && parent.rawNode.nodeType != Ci.nsIDOMNode.DOCUMENT_NODE) { + entries = entries.concat(this._getAllElementRules(parent, parent, + options)); + parent = this.walker.parentNode(parent); + } + + return entries.filter(entry => entry.rule.rawRule === filterRule); + }, + + /** + * Helper function for getApplied that fetches a set of style properties that + * apply to the given node and associated rules + * @param NodeActor node + * @param object options + * `filter`: A string filter that affects the "matched" handling. + * 'user': Include properties from user style sheets. + * 'ua': Include properties from user and user-agent sheets. + * Default value is 'ua' + * `inherited`: Include styles inherited from parent nodes. + * `matchedSelectors`: Include an array of specific selectors that + * caused this rule to match its node. + * @param array entries + * List of appliedstyle objects that lists the rules that apply to the + * node. If adding a new rule to the stylesheet, only the new rule entry + * is provided and only the style properties that apply to the new + * rule is fetched. + * @returns Object containing the list of rule entries, rule actors and + * stylesheet actors that applies to the given node and its associated + * rules. + */ + getAppliedProps: function (node, entries, options) { + if (options.inherited) { + let parent = this.walker.parentNode(node); + while (parent && parent.rawNode.nodeType != Ci.nsIDOMNode.DOCUMENT_NODE) { + entries = entries.concat(this._getAllElementRules(parent, parent, + options)); + parent = this.walker.parentNode(parent); + } + } + + if (options.matchedSelectors) { + for (let entry of entries) { + if (entry.rule.type === ELEMENT_STYLE) { + continue; + } + + let domRule = entry.rule.rawRule; + let selectors = CssLogic.getSelectors(domRule); + let element = entry.inherited ? entry.inherited.rawNode : node.rawNode; + + let {bindingElement, pseudo} = + CssLogic.getBindingElementAndPseudo(element); + entry.matchedSelectors = []; + for (let i = 0; i < selectors.length; i++) { + if (DOMUtils.selectorMatchesElement(bindingElement, domRule, i, + pseudo)) { + entry.matchedSelectors.push(selectors[i]); + } + } + } + } + + // Add all the keyframes rule associated with the element + let computedStyle = this.cssLogic.computedStyle; + if (computedStyle) { + let animationNames = computedStyle.animationName.split(","); + animationNames = animationNames.map(name => name.trim()); + + if (animationNames) { + // Traverse through all the available keyframes rule and add + // the keyframes rule that matches the computed animation name + for (let keyframesRule of this.cssLogic.keyframesRules) { + if (animationNames.indexOf(keyframesRule.name) > -1) { + for (let rule of keyframesRule.cssRules) { + entries.push({ + rule: this._styleRef(rule), + keyframes: this._styleRef(keyframesRule) + }); + } + } + } + } + } + + let rules = new Set(); + let sheets = new Set(); + entries.forEach(entry => rules.add(entry.rule)); + this.expandSets(rules, sheets); + + return { + entries: entries, + rules: [...rules], + sheets: [...sheets] + }; + }, + + /** + * Expand Sets of rules and sheets to include all parent rules and sheets. + */ + expandSets: function (ruleSet, sheetSet) { + // Sets include new items in their iteration + for (let rule of ruleSet) { + if (rule.rawRule.parentRule) { + let parent = this._styleRef(rule.rawRule.parentRule); + if (!ruleSet.has(parent)) { + ruleSet.add(parent); + } + } + if (rule.rawRule.parentStyleSheet) { + let parent = this._sheetRef(rule.rawRule.parentStyleSheet); + if (!sheetSet.has(parent)) { + sheetSet.add(parent); + } + } + } + + for (let sheet of sheetSet) { + if (sheet.rawSheet.parentStyleSheet) { + let parent = this._sheetRef(sheet.rawSheet.parentStyleSheet); + if (!sheetSet.has(parent)) { + sheetSet.add(parent); + } + } + } + }, + + /** + * Get layout-related information about a node. + * This method returns an object with properties giving information about + * the node's margin, border, padding and content region sizes, as well + * as information about the type of box, its position, z-index, etc... + * @param {NodeActor} node + * @param {Object} options The only available option is autoMargins. + * If set to true, the element's margins will receive an extra check to see + * whether they are set to "auto" (knowing that the computed-style in this + * case would return "0px"). + * The returned object will contain an extra property (autoMargins) listing + * all margins that are set to auto, e.g. {top: "auto", left: "auto"}. + * @return {Object} + */ + getLayout: function (node, options) { + this.cssLogic.highlight(node.rawNode); + + let layout = {}; + + // First, we update the first part of the box model view, with + // the size of the element. + + let clientRect = node.rawNode.getBoundingClientRect(); + layout.width = parseFloat(clientRect.width.toPrecision(6)); + layout.height = parseFloat(clientRect.height.toPrecision(6)); + + // We compute and update the values of margins & co. + let style = CssLogic.getComputedStyle(node.rawNode); + for (let prop of [ + "position", + "margin-top", + "margin-right", + "margin-bottom", + "margin-left", + "padding-top", + "padding-right", + "padding-bottom", + "padding-left", + "border-top-width", + "border-right-width", + "border-bottom-width", + "border-left-width", + "z-index", + "box-sizing", + "display" + ]) { + layout[prop] = style.getPropertyValue(prop); + } + + if (options.autoMargins) { + layout.autoMargins = this.processMargins(this.cssLogic); + } + + for (let i in this.map) { + let property = this.map[i].property; + this.map[i].value = parseFloat(style.getPropertyValue(property)); + } + + return layout; + }, + + /** + * Find 'auto' margin properties. + */ + processMargins: function (cssLogic) { + let margins = {}; + + for (let prop of ["top", "bottom", "left", "right"]) { + let info = cssLogic.getPropertyInfo("margin-" + prop); + let selectors = info.matchedSelectors; + if (selectors && selectors.length > 0 && selectors[0].value == "auto") { + margins[prop] = "auto"; + } + } + + return margins; + }, + + /** + * On page navigation, tidy up remaining objects. + */ + onFrameUnload: function () { + this.styleElements = new WeakMap(); + }, + + /** + * When a stylesheet is added, handle the related StyleSheetActor to listen for changes. + * @param {StyleSheetActor} actor + * The actor for the added stylesheet. + */ + onStyleSheetAdded: function (actor) { + if (!this._watchedSheets.has(actor)) { + this._watchedSheets.add(actor); + actor.on("style-applied", this._styleApplied); + } + }, + + /** + * Helper function to addNewRule to get or create a style tag in the provided + * document. + * + * @param {Document} document + * The document in which the style element should be appended. + * @returns DOMElement of the style tag + */ + getStyleElement: function (document) { + if (!this.styleElements.has(document)) { + let style = document.createElementNS(XHTML_NS, "style"); + style.setAttribute("type", "text/css"); + document.documentElement.appendChild(style); + this.styleElements.set(document, style); + } + + return this.styleElements.get(document); + }, + + /** + * Helper function for adding a new rule and getting its applied style + * properties + * @param NodeActor node + * @param CSSStyleRule rule + * @returns Object containing its applied style properties + */ + getNewAppliedProps: function (node, rule) { + let ruleActor = this._styleRef(rule); + return this.getAppliedProps(node, [{ rule: ruleActor }], + { matchedSelectors: true }); + }, + + /** + * Adds a new rule, and returns the new StyleRuleActor. + * @param {NodeActor} node + * @param {String} pseudoClasses The list of pseudo classes to append to the + * new selector. + * @param {Boolean} editAuthored + * True if the selector should be updated by editing the + * authored text; false if the selector should be updated via + * CSSOM. + * @returns {StyleRuleActor} the new rule + */ + addNewRule: Task.async(function* (node, pseudoClasses, editAuthored = false) { + let style = this.getStyleElement(node.rawNode.ownerDocument); + let sheet = style.sheet; + let cssRules = sheet.cssRules; + let rawNode = node.rawNode; + let classes = [...rawNode.classList]; + + let selector; + if (rawNode.id) { + selector = "#" + CSS.escape(rawNode.id); + } else if (classes.length > 0) { + selector = "." + classes.map(c => CSS.escape(c)).join("."); + } else { + selector = rawNode.localName; + } + + if (pseudoClasses && pseudoClasses.length > 0) { + selector += pseudoClasses.join(""); + } + + let index = sheet.insertRule(selector + " {}", cssRules.length); + + // If inserting the rule succeeded, go ahead and edit the source + // text if requested. + if (editAuthored) { + let sheetActor = this._sheetRef(sheet); + let {str: authoredText} = yield sheetActor.getText(); + authoredText += "\n" + selector + " {\n" + "}"; + yield sheetActor.update(authoredText, false); + } + + return this.getNewAppliedProps(node, sheet.cssRules.item(index)); + }) +}); +exports.PageStyleActor = PageStyleActor; + +/** + * An actor that represents a CSS style object on the protocol. + * + * We slightly flatten the CSSOM for this actor, it represents + * both the CSSRule and CSSStyle objects in one actor. For nodes + * (which have a CSSStyle but no CSSRule) we create a StyleRuleActor + * with a special rule type (100). + */ +var StyleRuleActor = protocol.ActorClassWithSpec(styleRuleSpec, { + initialize: function (pageStyle, item) { + protocol.Actor.prototype.initialize.call(this, null); + this.pageStyle = pageStyle; + this.rawStyle = item.style; + this._parentSheet = null; + this._onStyleApplied = this._onStyleApplied.bind(this); + + if (item instanceof (Ci.nsIDOMCSSRule)) { + this.type = item.type; + this.rawRule = item; + if ((this.type === Ci.nsIDOMCSSRule.STYLE_RULE || + this.type === Ci.nsIDOMCSSRule.KEYFRAME_RULE) && + this.rawRule.parentStyleSheet) { + this.line = DOMUtils.getRelativeRuleLine(this.rawRule); + this.column = DOMUtils.getRuleColumn(this.rawRule); + this._parentSheet = this.rawRule.parentStyleSheet; + this._computeRuleIndex(); + this.sheetActor = this.pageStyle._sheetRef(this._parentSheet); + this.sheetActor.on("style-applied", this._onStyleApplied); + } + } else { + // Fake a rule + this.type = ELEMENT_STYLE; + this.rawNode = item; + this.rawRule = { + style: item.style, + toString: function () { + return "[element rule " + this.style + "]"; + } + }; + } + }, + + get conn() { + return this.pageStyle.conn; + }, + + destroy: function () { + if (!this.rawStyle) { + return; + } + protocol.Actor.prototype.destroy.call(this); + this.rawStyle = null; + this.pageStyle = null; + this.rawNode = null; + this.rawRule = null; + if (this.sheetActor) { + this.sheetActor.off("style-applied", this._onStyleApplied); + } + }, + + // Objects returned by this actor are owned by the PageStyleActor + // to which this rule belongs. + get marshallPool() { + return this.pageStyle; + }, + + // True if this rule supports as-authored styles, meaning that the + // rule text can be rewritten using setRuleText. + get canSetRuleText() { + return this.type === ELEMENT_STYLE || + (this._parentSheet && + // If a rule does not have source, then it has been modified via + // CSSOM; and we should fall back to non-authored editing. + // https://bugzilla.mozilla.org/show_bug.cgi?id=1224121 + this.sheetActor.allRulesHaveSource() && + // Special case about:PreferenceStyleSheet, as it is generated on + // the fly and the URI is not registered with the about:handler + // https://bugzilla.mozilla.org/show_bug.cgi?id=935803#c37 + this._parentSheet.href !== "about:PreferenceStyleSheet"); + }, + + getDocument: function (sheet) { + let document; + + if (sheet.ownerNode instanceof Ci.nsIDOMHTMLDocument) { + document = sheet.ownerNode; + } else { + document = sheet.ownerNode.ownerDocument; + } + + return document; + }, + + toString: function () { + return "[StyleRuleActor for " + this.rawRule + "]"; + }, + + form: function (detail) { + if (detail === "actorid") { + return this.actorID; + } + + let form = { + actor: this.actorID, + type: this.type, + line: this.line || undefined, + column: this.column, + traits: { + // Whether the style rule actor implements the modifySelector2 method + // that allows for unmatched rule to be added + modifySelectorUnmatched: true, + // Whether the style rule actor implements the setRuleText + // method. + canSetRuleText: this.canSetRuleText, + } + }; + + if (this.rawRule.parentRule) { + form.parentRule = + this.pageStyle._styleRef(this.rawRule.parentRule).actorID; + + // CSS rules that we call media rules are STYLE_RULES that are children + // of MEDIA_RULEs. We need to check the parentRule to check if a rule is + // a media rule so we do this here instead of in the switch statement + // below. + if (this.rawRule.parentRule.type === Ci.nsIDOMCSSRule.MEDIA_RULE) { + form.media = []; + for (let i = 0, n = this.rawRule.parentRule.media.length; i < n; i++) { + form.media.push(this.rawRule.parentRule.media.item(i)); + } + } + } + if (this._parentSheet) { + form.parentStyleSheet = + this.pageStyle._sheetRef(this._parentSheet).actorID; + } + + // One tricky thing here is that other methods in this actor must + // ensure that authoredText has been set before |form| is called. + // This has to be treated specially, for now, because we cannot + // synchronously compute the authored text, but |form| also cannot + // return a promise. See bug 1205868. + form.authoredText = this.authoredText; + + switch (this.type) { + case Ci.nsIDOMCSSRule.STYLE_RULE: + form.selectors = CssLogic.getSelectors(this.rawRule); + form.cssText = this.rawStyle.cssText || ""; + break; + case ELEMENT_STYLE: + // Elements don't have a parent stylesheet, and therefore + // don't have an associated URI. Provide a URI for + // those. + let doc = this.rawNode.ownerDocument; + form.href = doc.location ? doc.location.href : ""; + form.cssText = this.rawStyle.cssText || ""; + form.authoredText = this.rawNode.getAttribute("style"); + break; + case Ci.nsIDOMCSSRule.CHARSET_RULE: + form.encoding = this.rawRule.encoding; + break; + case Ci.nsIDOMCSSRule.IMPORT_RULE: + form.href = this.rawRule.href; + break; + case Ci.nsIDOMCSSRule.KEYFRAMES_RULE: + form.cssText = this.rawRule.cssText; + form.name = this.rawRule.name; + break; + case Ci.nsIDOMCSSRule.KEYFRAME_RULE: + form.cssText = this.rawStyle.cssText || ""; + form.keyText = this.rawRule.keyText || ""; + break; + } + + // Parse the text into a list of declarations so the client doesn't have to + // and so that we can safely determine if a declaration is valid rather than + // have the client guess it. + if (form.authoredText || form.cssText) { + let declarations = parseDeclarations(isCssPropertyKnown, + form.authoredText || form.cssText, + true); + form.declarations = declarations.map(decl => { + decl.isValid = DOMUtils.cssPropertyIsValid(decl.name, decl.value); + return decl; + }); + } + + return form; + }, + + /** + * Send an event notifying that the location of the rule has + * changed. + * + * @param {Number} line the new line number + * @param {Number} column the new column number + */ + _notifyLocationChanged: function (line, column) { + events.emit(this, "location-changed", line, column); + }, + + /** + * Compute the index of this actor's raw rule in its parent style + * sheet. The index is a vector where each element is the index of + * a given CSS rule in its parent. A vector is used to support + * nested rules. + */ + _computeRuleIndex: function () { + let rule = this.rawRule; + let result = []; + + while (rule) { + let cssRules; + if (rule.parentRule) { + cssRules = rule.parentRule.cssRules; + } else { + cssRules = rule.parentStyleSheet.cssRules; + } + + let found = false; + for (let i = 0; i < cssRules.length; i++) { + if (rule === cssRules.item(i)) { + found = true; + result.unshift(i); + break; + } + } + + if (!found) { + this._ruleIndex = null; + return; + } + + rule = rule.parentRule; + } + + this._ruleIndex = result; + }, + + /** + * Get the rule corresponding to |this._ruleIndex| from the given + * style sheet. + * + * @param {DOMStyleSheet} sheet + * The style sheet. + * @return {CSSStyleRule} the rule corresponding to + * |this._ruleIndex| + */ + _getRuleFromIndex: function (parentSheet) { + let currentRule = null; + for (let i of this._ruleIndex) { + if (currentRule === null) { + currentRule = parentSheet.cssRules[i]; + } else { + currentRule = currentRule.cssRules.item(i); + } + } + return currentRule; + }, + + /** + * This is attached to the parent style sheet actor's + * "style-applied" event. + */ + _onStyleApplied: function (kind) { + if (kind === UPDATE_GENERAL) { + // A general change means that the rule actors are invalidated, + // so stop listening to events now. + if (this.sheetActor) { + this.sheetActor.off("style-applied", this._onStyleApplied); + } + } else if (this._ruleIndex) { + // The sheet was updated by this actor, in a way that preserves + // the rules. Now, recompute our new rule from the style sheet, + // so that we aren't left with a reference to a dangling rule. + let oldRule = this.rawRule; + this.rawRule = this._getRuleFromIndex(this._parentSheet); + // Also tell the page style so that future calls to _styleRef + // return the same StyleRuleActor. + this.pageStyle.updateStyleRef(oldRule, this.rawRule, this); + let line = DOMUtils.getRelativeRuleLine(this.rawRule); + let column = DOMUtils.getRuleColumn(this.rawRule); + if (line !== this.line || column !== this.column) { + this._notifyLocationChanged(line, column); + } + this.line = line; + this.column = column; + } + }, + + /** + * Return a promise that resolves to the authored form of a rule's + * text, if available. If the authored form is not available, the + * returned promise simply resolves to the empty string. If the + * authored form is available, this also sets |this.authoredText|. + * The authored text will include invalid and otherwise ignored + * properties. + */ + getAuthoredCssText: function () { + if (!this.canSetRuleText || + (this.type !== Ci.nsIDOMCSSRule.STYLE_RULE && + this.type !== Ci.nsIDOMCSSRule.KEYFRAME_RULE)) { + return promise.resolve(""); + } + + if (typeof this.authoredText === "string") { + return promise.resolve(this.authoredText); + } + + let parentStyleSheet = + this.pageStyle._sheetRef(this._parentSheet); + return parentStyleSheet.getText().then((longStr) => { + let cssText = longStr.str; + let {text} = getRuleText(cssText, this.line, this.column); + + // Cache the result on the rule actor to avoid parsing again next time + this.authoredText = text; + return this.authoredText; + }); + }, + + /** + * Set the contents of the rule. This rewrites the rule in the + * stylesheet and causes it to be re-evaluated. + * + * @param {String} newText the new text of the rule + * @returns the rule with updated properties + */ + setRuleText: Task.async(function* (newText) { + if (!this.canSetRuleText) { + throw new Error("invalid call to setRuleText"); + } + + if (this.type === ELEMENT_STYLE) { + // For element style rules, set the node's style attribute. + this.rawNode.setAttribute("style", newText); + } else { + // For stylesheet rules, set the text in the stylesheet. + let parentStyleSheet = this.pageStyle._sheetRef(this._parentSheet); + let {str: cssText} = yield parentStyleSheet.getText(); + + let {offset, text} = getRuleText(cssText, this.line, this.column); + cssText = cssText.substring(0, offset) + newText + + cssText.substring(offset + text.length); + + yield parentStyleSheet.update(cssText, false, UPDATE_PRESERVING_RULES); + } + + this.authoredText = newText; + + return this; + }), + + /** + * Modify a rule's properties. Passed an array of modifications: + * { + * type: "set", + * name: <string>, + * value: <string>, + * priority: <optional string> + * } + * or + * { + * type: "remove", + * name: <string>, + * } + * + * @returns the rule with updated properties + */ + modifyProperties: function (modifications) { + // Use a fresh element for each call to this function to prevent side + // effects that pop up based on property values that were already set on the + // element. + + let document; + if (this.rawNode) { + document = this.rawNode.ownerDocument; + } else { + let parentStyleSheet = this._parentSheet; + while (parentStyleSheet.ownerRule && + parentStyleSheet.ownerRule instanceof Ci.nsIDOMCSSImportRule) { + parentStyleSheet = parentStyleSheet.ownerRule.parentStyleSheet; + } + + document = this.getDocument(parentStyleSheet); + } + + let tempElement = document.createElementNS(XHTML_NS, "div"); + + for (let mod of modifications) { + if (mod.type === "set") { + tempElement.style.setProperty(mod.name, mod.value, mod.priority || ""); + this.rawStyle.setProperty(mod.name, + tempElement.style.getPropertyValue(mod.name), mod.priority || ""); + } else if (mod.type === "remove") { + this.rawStyle.removeProperty(mod.name); + } + } + + return this; + }, + + /** + * Helper function for modifySelector and modifySelector2, inserts the new + * rule with the new selector into the parent style sheet and removes the + * current rule. Returns the newly inserted css rule or null if the rule is + * unsuccessfully inserted to the parent style sheet. + * + * @param {String} value + * The new selector value + * @param {Boolean} editAuthored + * True if the selector should be updated by editing the + * authored text; false if the selector should be updated via + * CSSOM. + * + * @returns {CSSRule} + * The new CSS rule added + */ + _addNewSelector: Task.async(function* (value, editAuthored) { + let rule = this.rawRule; + let parentStyleSheet = this._parentSheet; + + // We know the selector modification is ok, so if the client asked + // for the authored text to be edited, do it now. + if (editAuthored) { + let document = this.getDocument(this._parentSheet); + try { + document.querySelector(value); + } catch (e) { + return null; + } + + let sheetActor = this.pageStyle._sheetRef(parentStyleSheet); + let {str: authoredText} = yield sheetActor.getText(); + let [startOffset, endOffset] = getSelectorOffsets(authoredText, this.line, + this.column); + authoredText = authoredText.substring(0, startOffset) + value + + authoredText.substring(endOffset); + yield sheetActor.update(authoredText, false, UPDATE_PRESERVING_RULES); + } else { + let cssRules = parentStyleSheet.cssRules; + let cssText = rule.cssText; + let selectorText = rule.selectorText; + + for (let i = 0; i < cssRules.length; i++) { + if (rule === cssRules.item(i)) { + try { + // Inserts the new style rule into the current style sheet and + // delete the current rule + let ruleText = cssText.slice(selectorText.length).trim(); + parentStyleSheet.insertRule(value + " " + ruleText, i); + parentStyleSheet.deleteRule(i + 1); + break; + } catch (e) { + // The selector could be invalid, or the rule could fail to insert. + return null; + } + } + } + } + + return this._getRuleFromIndex(parentStyleSheet); + }), + + /** + * Modify the current rule's selector by inserting a new rule with the new + * selector value and removing the current rule. + * + * Note this method was kept for backward compatibility, but unmatched rules + * support was added in FF41. + * + * @param string value + * The new selector value + * @returns boolean + * Returns a boolean if the selector in the stylesheet was modified, + * and false otherwise + */ + modifySelector: Task.async(function* (value) { + if (this.type === ELEMENT_STYLE) { + return false; + } + + let document = this.getDocument(this._parentSheet); + // Extract the selector, and pseudo elements and classes + let [selector] = value.split(/(:{1,2}.+$)/); + let selectorElement; + + try { + selectorElement = document.querySelector(selector); + } catch (e) { + return false; + } + + // Check if the selector is valid and not the same as the original + // selector + if (selectorElement && this.rawRule.selectorText !== value) { + yield this._addNewSelector(value, false); + return true; + } + return false; + }), + + /** + * Modify the current rule's selector by inserting a new rule with the new + * selector value and removing the current rule. + * + * In contrast with the modifySelector method which was used before FF41, + * this method also returns information about the new rule and applied style + * so that consumers can immediately display the new rule, whether or not the + * selector matches the current element without having to refresh the whole + * list. + * + * @param {DOMNode} node + * The current selected element + * @param {String} value + * The new selector value + * @param {Boolean} editAuthored + * True if the selector should be updated by editing the + * authored text; false if the selector should be updated via + * CSSOM. + * @returns {Object} + * Returns an object that contains the applied style properties of the + * new rule and a boolean indicating whether or not the new selector + * matches the current selected element + */ + modifySelector2: function (node, value, editAuthored = false) { + if (this.type === ELEMENT_STYLE || + this.rawRule.selectorText === value) { + return { ruleProps: null, isMatching: true }; + } + + let selectorPromise = this._addNewSelector(value, editAuthored); + + if (editAuthored) { + selectorPromise = selectorPromise.then((newCssRule) => { + if (newCssRule) { + let style = this.pageStyle._styleRef(newCssRule); + // See the comment in |form| to understand this. + return style.getAuthoredCssText().then(() => newCssRule); + } + return newCssRule; + }); + } + + return selectorPromise.then((newCssRule) => { + let ruleProps = null; + let isMatching = false; + + if (newCssRule) { + let ruleEntry = this.pageStyle.findEntryMatchingRule(node, newCssRule); + if (ruleEntry.length === 1) { + ruleProps = + this.pageStyle.getAppliedProps(node, ruleEntry, + { matchedSelectors: true }); + } else { + ruleProps = this.pageStyle.getNewAppliedProps(node, newCssRule); + } + + isMatching = ruleProps.entries.some((ruleProp) => + ruleProp.matchedSelectors.length > 0); + } + + return { ruleProps, isMatching }; + }); + } +}); + +/** + * Helper function for getting an image preview of the given font. + * + * @param font {string} + * Name of font to preview + * @param doc {Document} + * Document to use to render font + * @param options {object} + * Object with options 'previewText' and 'previewFontSize' + * + * @return dataUrl + * The data URI of the font preview image + */ +function getFontPreviewData(font, doc, options) { + options = options || {}; + let previewText = options.previewText || FONT_PREVIEW_TEXT; + let previewFontSize = options.previewFontSize || FONT_PREVIEW_FONT_SIZE; + let fillStyle = options.fillStyle || FONT_PREVIEW_FILLSTYLE; + let fontStyle = options.fontStyle || ""; + + let canvas = doc.createElementNS(XHTML_NS, "canvas"); + let ctx = canvas.getContext("2d"); + let fontValue = fontStyle + " " + previewFontSize + "px " + font + ", serif"; + + // Get the correct preview text measurements and set the canvas dimensions + ctx.font = fontValue; + ctx.fillStyle = fillStyle; + let textWidth = ctx.measureText(previewText).width; + + canvas.width = textWidth * 2 + FONT_PREVIEW_OFFSET * 2; + canvas.height = previewFontSize * 3; + + // we have to reset these after changing the canvas size + ctx.font = fontValue; + ctx.fillStyle = fillStyle; + + // Oversample the canvas for better text quality + ctx.textBaseline = "top"; + ctx.scale(2, 2); + ctx.fillText(previewText, + FONT_PREVIEW_OFFSET, + Math.round(previewFontSize / 3)); + + let dataURL = canvas.toDataURL("image/png"); + + return { + dataURL: dataURL, + size: textWidth + FONT_PREVIEW_OFFSET * 2 + }; +} + +exports.getFontPreviewData = getFontPreviewData; + +/** + * Get the text content of a rule given some CSS text, a line and a column + * Consider the following example: + * body { + * color: red; + * } + * p { + * line-height: 2em; + * color: blue; + * } + * Calling the function with the whole text above and line=4 and column=1 would + * return "line-height: 2em; color: blue;" + * @param {String} initialText + * @param {Number} line (1-indexed) + * @param {Number} column (1-indexed) + * @return {object} An object of the form {offset: number, text: string} + * The offset is the index into the input string where + * the rule text started. The text is the content of + * the rule. + */ +function getRuleText(initialText, line, column) { + if (typeof line === "undefined" || typeof column === "undefined") { + throw new Error("Location information is missing"); + } + + let {offset: textOffset, text} = + getTextAtLineColumn(initialText, line, column); + let lexer = DOMUtils.getCSSLexer(text); + + // Search forward for the opening brace. + while (true) { + let token = lexer.nextToken(); + if (!token) { + throw new Error("couldn't find start of the rule"); + } + if (token.tokenType === "symbol" && token.text === "{") { + break; + } + } + + // Now collect text until we see the matching close brace. + let braceDepth = 1; + let startOffset, endOffset; + while (true) { + let token = lexer.nextToken(); + if (!token) { + break; + } + if (startOffset === undefined) { + startOffset = token.startOffset; + } + if (token.tokenType === "symbol") { + if (token.text === "{") { + ++braceDepth; + } else if (token.text === "}") { + --braceDepth; + if (braceDepth == 0) { + break; + } + } + } + endOffset = token.endOffset; + } + + // If the rule was of the form "selector {" with no closing brace + // and no properties, just return an empty string. + if (startOffset === undefined) { + return {offset: 0, text: ""}; + } + // If the input didn't have any tokens between the braces (e.g., + // "div {}"), then the endOffset won't have been set yet; so account + // for that here. + if (endOffset === undefined) { + endOffset = startOffset; + } + + // Note that this approach will preserve comments, despite the fact + // that cssTokenizer skips them. + return {offset: textOffset + startOffset, + text: text.substring(startOffset, endOffset)}; +} + +exports.getRuleText = getRuleText; + +/** + * Compute the start and end offsets of a rule's selector text, given + * the CSS text and the line and column at which the rule begins. + * @param {String} initialText + * @param {Number} line (1-indexed) + * @param {Number} column (1-indexed) + * @return {array} An array with two elements: [startOffset, endOffset]. + * The elements mark the bounds in |initialText| of + * the CSS rule's selector. + */ +function getSelectorOffsets(initialText, line, column) { + if (typeof line === "undefined" || typeof column === "undefined") { + throw new Error("Location information is missing"); + } + + let {offset: textOffset, text} = + getTextAtLineColumn(initialText, line, column); + let lexer = DOMUtils.getCSSLexer(text); + + // Search forward for the opening brace. + let endOffset; + while (true) { + let token = lexer.nextToken(); + if (!token) { + break; + } + if (token.tokenType === "symbol" && token.text === "{") { + if (endOffset === undefined) { + break; + } + return [textOffset, textOffset + endOffset]; + } + // Preserve comments and whitespace just before the "{". + if (token.tokenType !== "comment" && token.tokenType !== "whitespace") { + endOffset = token.endOffset; + } + } + + throw new Error("could not find bounds of rule"); +} + +/** + * Return the offset and substring of |text| that starts at the given + * line and column. + * @param {String} text + * @param {Number} line (1-indexed) + * @param {Number} column (1-indexed) + * @return {object} An object of the form {offset: number, text: string}, + * where the offset is the offset into the input string + * where the text starts, and where text is the text. + */ +function getTextAtLineColumn(text, line, column) { + let offset; + if (line > 1) { + let rx = new RegExp("(?:.*(?:\\r\\n|\\n|\\r|\\f)){" + (line - 1) + "}"); + offset = rx.exec(text)[0].length; + } else { + offset = 0; + } + offset += column - 1; + return {offset: offset, text: text.substr(offset) }; +} + +exports.getTextAtLineColumn = getTextAtLineColumn; |