/* 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/. */ /* globals CSS */ "use strict"; const {Cc, Ci, Cu} = require("chrome"); const promise = require("promise"); const protocol = require("devtools/server/protocol"); const {Arg, Option, method, RetVal, types} = protocol; const events = require("sdk/event/core"); const {Class} = require("sdk/core/heritage"); const {LongStringActor} = require("devtools/server/actors/string"); const {PSEUDO_ELEMENT_SET} = require("devtools/shared/styleinspector/css-logic"); // This will also add the "stylesheet" actor type for protocol.js to recognize const {UPDATE_PRESERVING_RULES, UPDATE_GENERAL} = require("devtools/server/actors/stylesheets"); loader.lazyGetter(this, "CssLogic", () => { return require("devtools/shared/styleinspector/css-logic").CssLogic; }); loader.lazyGetter(this, "DOMUtils", () => { return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils); }); loader.lazyGetter(this, "RuleRewriter", () => { return require("devtools/client/shared/css-parsing-utils").RuleRewriter; }); // The PageStyle actor flattens the DOM CSS objects a little bit, merging // Rules and their Styles into one actor. For elements (which have a style // but no associated rule) we fake a rule with the following style id. const ELEMENT_STYLE = 100; exports.ELEMENT_STYLE = ELEMENT_STYLE; // Not included since these are uneditable by the user. // See https://hg.mozilla.org/mozilla-central/file/696a4ad5d011/layout/style/nsCSSPseudoElementList.h#l74 PSEUDO_ELEMENT_SET.delete(":-moz-meter-bar"); PSEUDO_ELEMENT_SET.delete(":-moz-list-bullet"); PSEUDO_ELEMENT_SET.delete(":-moz-list-number"); PSEUDO_ELEMENT_SET.delete(":-moz-focus-inner"); PSEUDO_ELEMENT_SET.delete(":-moz-focus-outer"); PSEUDO_ELEMENT_SET.delete(":-moz-math-anonymous"); PSEUDO_ELEMENT_SET.delete(":-moz-math-stretchy"); const PSEUDO_ELEMENTS = Array.from(PSEUDO_ELEMENT_SET); exports.PSEUDO_ELEMENTS = PSEUDO_ELEMENTS; // When gathering rules to read for pseudo elements, we will skip // :before and :after, which are handled as a special case. const PSEUDO_ELEMENTS_TO_READ = PSEUDO_ELEMENTS.filter(pseudo => { return pseudo !== ":before" && pseudo !== ":after"; }); 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; // Predeclare the domnode actor type for use in requests. types.addActorType("domnode"); // Predeclare the domstylerule actor type types.addActorType("domstylerule"); /** * DOM Nodes returned by the style actor will be owned by the DOM walker * for the connection. */ types.addLifetime("walker", "walker"); /** * When asking for the styles applied to a node, we return a list of * appliedstyle json objects that lists the rules that apply to the node * and which element they were inherited from (if any). * * Note appliedstyle only sends the list of actorIDs and is not a valid return * value on its own. appliedstyle should be returned with the actual list of * StyleRuleActor and StyleSheetActor. See appliedStylesReturn. */ types.addDictType("appliedstyle", { rule: "domstylerule#actorid", inherited: "nullable:domnode#actorid", keyframes: "nullable:domstylerule#actorid" }); types.addDictType("matchedselector", { rule: "domstylerule#actorid", selector: "string", value: "string", status: "number" }); types.addDictType("appliedStylesReturn", { entries: "array:appliedstyle", rules: "array:domstylerule", sheets: "array:stylesheet" }); types.addDictType("modifiedStylesReturn", { isMatching: RetVal("boolean"), ruleProps: RetVal("nullable:appliedStylesReturn") }); types.addDictType("fontpreview", { data: "nullable:longstring", size: "json" }); types.addDictType("fontface", { name: "string", CSSFamilyName: "string", rule: "nullable:domstylerule", srcIndex: "number", URI: "string", format: "string", preview: "nullable:fontpreview", localName: "string", metadata: "string" }); /** * The PageStyle actor lets the client look at the styles on a page, as * they are applied to a given node. */ var PageStyleActor = protocol.ActorClass({ typeName: "pagestyle", events: { "stylesheet-updated": { type: "styleSheetUpdated", styleSheet: Arg(0, "stylesheet") } }, /** * 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(); // Stores the association of DOM objects -> actors this.refMap = new Map(); this.onFrameUnload = this.onFrameUnload.bind(this); events.on(this.inspector.tabActor, "will-navigate", this.onFrameUnload); 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); this.inspector = null; this.walker = null; this.refMap = null; this.cssLogic = null; this._styleElement = 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); if (!this._watchedSheets.has(actor)) { this._watchedSheets.add(actor); actor.on("style-applied", this._styleApplied); } 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" * matched: * }, * ... * } */ getComputed: method(function(node, options) { let ret = Object.create(null); this.cssLogic.sourceFilter = options.filter || CssLogic.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; }, { request: { node: Arg(0, "domnode"), markMatched: Option(1, "boolean"), onlyMatched: Option(1, "boolean"), filter: Option(1, "string"), }, response: { computed: RetVal("json") } }), /** * 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: method(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; }, { request: { includePreviews: Option(0, "boolean"), previewText: Option(0, "string"), previewFontSize: Option(0, "string"), previewFillStyle: Option(0, "string") }, response: { fontFaces: RetVal("array:fontface") } }), /** * 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: method(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; }, { request: { node: Arg(0, "domnode"), includePreviews: Option(1, "boolean"), previewText: Option(1, "string"), previewFontSize: Option(1, "string"), previewFillStyle: Option(1, "string") }, response: { fontFaces: RetVal("array:fontface") } }), /** * 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: , * sourceText: , // The source of the selector, relative * // to the node in question. * selector: , // the selector ID that matched * value: , // the value of the property * status: , * // 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: [ , ... ], // The full form of any domrule referenced * * // The full form of any sheets referenced. * sheets: [ , ... ] * } */ getMatchedSelectors: method(function(node, property, options) { this.cssLogic.sourceFilter = options.filter || CssLogic.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] }; }, { request: { node: Arg(0, "domnode"), property: Arg(1, "string"), filter: Option(2, "string") }, response: RetVal(types.addDictType("matchedselectorresponse", { rules: "array:domstylerule", sheets: "array:stylesheet", matched: "array:matchedselector" })) }), // 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: method(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; }), { request: { node: Arg(0, "domnode"), inherited: Option(1, "boolean"), matchedSelectors: Option(1, "boolean"), filter: Option(1, "string") }, response: RetVal("appliedStylesReturn") }), _hasInheritedProps: function(style) { return Array.prototype.some.call(style, prop => { return DOMUtils.isInheritedProperty(prop); }); }, /** * 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(rule => { // 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). rule.pseudoElement = null; rules.push(rule); }); // Now any pseudos (except for ::before / ::after, which was handled as // a 'normal rule' above. if (showElementStyles) { for (let pseudo of PSEUDO_ELEMENTS_TO_READ) { this._getElementRules(bindingElement, pseudo, inherited, options).forEach(rule => { rules.push(rule); }); } } 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 = !CssLogic.isContentStylesheet(domRule.parentStyleSheet); if (isSystem && options.filter != CssLogic.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; }, /** * 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. * `matchedSeletors`: 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: method(function(node, options) { this.cssLogic.highlight(node.rawNode); let layout = {}; // First, we update the first part of the layout 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; }, { request: { node: Arg(0, "domnode"), autoMargins: Option(1, "boolean") }, response: RetVal("json") }), /** * 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._styleElement = null; }, /** * Helper function to addNewRule to construct a new style tag in the document. * @returns DOMElement of the style tag */ get styleElement() { if (!this._styleElement) { let document = this.inspector.window.document; let style = document.createElementNS(XHTML_NS, "style"); style.setAttribute("type", "text/css"); document.documentElement.appendChild(style); this._styleElement = style; } return this._styleElement; }, /** * 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: method(Task.async(function*(node, pseudoClasses, editAuthored = false) { let style = this.styleElement; let sheet = style.sheet; let cssRules = sheet.cssRules; let rawNode = node.rawNode; let selector; if (rawNode.id) { selector = "#" + CSS.escape(rawNode.id); } else if (rawNode.className) { selector = "." + [...rawNode.classList].map(c => CSS.escape(c)).join("."); } else { selector = rawNode.tagName.toLowerCase(); } 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)); }), { request: { node: Arg(0, "domnode"), pseudoClasses: Arg(1, "nullable:array:string"), editAuthored: Arg(2, "boolean") }, response: RetVal("appliedStylesReturn") }), }); exports.PageStyleActor = PageStyleActor; /** * Front object for the PageStyleActor */ var PageStyleFront = protocol.FrontClass(PageStyleActor, { initialize: function(conn, form, ctx, detail) { protocol.Front.prototype.initialize.call(this, conn, form, ctx, detail); this.inspector = this.parent(); }, form: function(form, detail) { if (detail === "actorid") { this.actorID = form; return; } this._form = form; }, destroy: function() { protocol.Front.prototype.destroy.call(this); }, get walker() { return this.inspector.walker; }, get supportsAuthoredStyles() { return this._form.traits && this._form.traits.authoredStyles; }, getMatchedSelectors: protocol.custom(function(node, property, options) { return this._getMatchedSelectors(node, property, options).then(ret => { return ret.matched; }); }, { impl: "_getMatchedSelectors" }), getApplied: protocol.custom(Task.async(function*(node, options={}) { // If the getApplied method doesn't recreate the style cache itself, this // means a call to cssLogic.highlight is required before trying to access // the applied rules. Issue a request to getLayout if this is the case. // See https://bugzilla.mozilla.org/show_bug.cgi?id=1103993#c16. if (!this._form.traits || !this._form.traits.getAppliedCreatesStyleCache) { yield this.getLayout(node); } let ret = yield this._getApplied(node, options); return ret.entries; }), { impl: "_getApplied" }), addNewRule: protocol.custom(function(node, pseudoClasses) { let addPromise; if (this.supportsAuthoredStyles) { addPromise = this._addNewRule(node, pseudoClasses, true); } else { addPromise = this._addNewRule(node, pseudoClasses); } return addPromise.then(ret => { return ret.entries[0]; }); }, { impl: "_addNewRule" }) }); /** * 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.ActorClass({ typeName: "domstylerule", events: { "location-changed": { type: "locationChanged", line: Arg(0, "number"), column: Arg(1, "number") }, }, 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() { // 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 return !!(this._parentSheet && 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 || ""; 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; } 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: method(Task.async(function*(newText) { if (!this.canSetRuleText || (this.type !== Ci.nsIDOMCSSRule.STYLE_RULE && this.type !== Ci.nsIDOMCSSRule.KEYFRAME_RULE)) { throw new Error("invalid call to setRuleText"); } 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); this.authoredText = newText; yield parentStyleSheet.update(cssText, false, UPDATE_PRESERVING_RULES); return this; }), { request: { modification: Arg(0, "string") }, response: { rule: RetVal("domstylerule") } }), /** * Modify a rule's properties. Passed an array of modifications: * { * type: "set", * name: , * value: , * priority: * } * or * { * type: "remove", * name: , * } * * @returns the rule with updated properties */ modifyProperties: method(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; }, { request: { modifications: Arg(0, "array:json") }, response: { rule: RetVal("domstylerule") } }), /** * 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: method(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, pseudoProp] = 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; }), { request: { selector: Arg(0, "string") }, response: { isModified: RetVal("boolean") }, }), /** * 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: method(function(node, value, editAuthored = false) { let ruleProps = null; if (this.type === ELEMENT_STYLE || this.rawRule.selectorText === value) { return { ruleProps, 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) => { if (newCssRule) { ruleProps = this.pageStyle.getNewAppliedProps(node, newCssRule); } // Determine if the new selector value matches the current // selected element let isMatching = false; try { isMatching = node.rawNode.matches(value); } catch(e) { // This fails when value is an invalid selector. } return { ruleProps, isMatching }; }); }, { request: { node: Arg(0, "domnode"), value: Arg(1, "string"), editAuthored: Arg(2, "boolean") }, response: RetVal("modifiedStylesReturn") }) }); /** * Front for the StyleRule actor. */ var StyleRuleFront = protocol.FrontClass(StyleRuleActor, { initialize: function(client, form, ctx, detail) { protocol.Front.prototype.initialize.call(this, client, form, ctx, detail); }, destroy: function() { protocol.Front.prototype.destroy.call(this); }, form: function(form, detail) { if (detail === "actorid") { this.actorID = form; return; } this.actorID = form.actor; this._form = form; if (this._mediaText) { this._mediaText = null; } }, /** * Ensure _form is updated when location-changed is emitted. */ _locationChangedPre: protocol.preEvent("location-changed", function(line, column) { this._clearOriginalLocation(); this._form.line = line; this._form.column = column; }), /** * Return a new RuleModificationList or RuleRewriter for this node. * A RuleRewriter will be returned when the rule's canSetRuleText * trait is true; otherwise a RuleModificationList will be * returned. */ startModifyingProperties: function() { if (this.canSetRuleText) { return new RuleRewriter(this, this.authoredText); } return new RuleModificationList(this); }, get type() { return this._form.type; }, get line() { return this._form.line || -1; }, get column() { return this._form.column || -1; }, get cssText() { return this._form.cssText; }, get authoredText() { return this._form.authoredText || this._form.cssText; }, get keyText() { return this._form.keyText; }, get name() { return this._form.name; }, get selectors() { return this._form.selectors; }, get media() { return this._form.media; }, get mediaText() { if (!this._form.media) { return null; } if (this._mediaText) { return this._mediaText; } this._mediaText = this.media.join(", "); return this._mediaText; }, get parentRule() { return this.conn.getActor(this._form.parentRule); }, get parentStyleSheet() { return this.conn.getActor(this._form.parentStyleSheet); }, get element() { return this.conn.getActor(this._form.element); }, get href() { if (this._form.href) { return this._form.href; } let sheet = this.parentStyleSheet; return sheet ? sheet.href : ""; }, get nodeHref() { let sheet = this.parentStyleSheet; return sheet ? sheet.nodeHref : ""; }, get supportsModifySelectorUnmatched() { return this._form.traits && this._form.traits.modifySelectorUnmatched; }, get canSetRuleText() { return this._form.traits && this._form.traits.canSetRuleText; }, get location() { return { source: this.parentStyleSheet, href: this.href, line: this.line, column: this.column }; }, _clearOriginalLocation: function() { this._originalLocation = null; }, getOriginalLocation: function() { if (this._originalLocation) { return promise.resolve(this._originalLocation); } let parentSheet = this.parentStyleSheet; if (!parentSheet) { // This rule doesn't belong to a stylesheet so it is an inline style. // Inline styles do not have any mediaText so we can return early. return promise.resolve(this.location); } return parentSheet.getOriginalLocation(this.line, this.column) .then(({ fromSourceMap, source, line, column }) => { let location = { href: source, line: line, column: column, mediaText: this.mediaText }; if (fromSourceMap === false) { location.source = this.parentStyleSheet; } if (!source) { location.href = this.href; } this._originalLocation = location; return location; }); }, modifySelector: protocol.custom(Task.async(function*(node, value) { let response; if (this.supportsModifySelectorUnmatched) { // If the debugee supports adding unmatched rules (post FF41) if (this.canSetRuleText) { response = yield this.modifySelector2(node, value, true); } else { response = yield this.modifySelector2(node, value); } } else { response = yield this._modifySelector(value); } if (response.ruleProps) { response.ruleProps = response.ruleProps.entries[0]; } return response; }), { impl: "_modifySelector" }), setRuleText: protocol.custom(function(newText) { this._form.authoredText = newText; return this._setRuleText(newText); }, { impl: "_setRuleText" }) }); /** * Convenience API for building a list of attribute modifications * for the `modifyProperties` request. A RuleModificationList holds a * list of modifications that will be applied to a StyleRuleActor. * The modifications are processed in the order in which they are * added to the RuleModificationList. * * Objects of this type expose the same API as @see RuleRewriter. * This lets the inspector use (mostly) the same code, regardless of * whether the server implements setRuleText. */ var RuleModificationList = Class({ /** * Initialize a RuleModificationList. * @param {StyleRuleFront} rule the associated rule */ initialize: function(rule) { this.rule = rule; this.modifications = []; }, /** * Apply the modifications in this object to the associated rule. * * @return {Promise} A promise which will be resolved when the modifications * are complete; @see StyleRuleActor.modifyProperties. */ apply: function() { return this.rule.modifyProperties(this.modifications); }, /** * Add a "set" entry to the modification list. * * @param {Number} index index of the property in the rule. * This can be -1 in the case where * the rule does not support setRuleText; * generally for setting properties * on an element's style. * @param {String} name the property's name * @param {String} value the property's value * @param {String} priority the property's priority, either the empty * string or "important" */ setProperty: function(index, name, value, priority) { this.modifications.push({ type: "set", name: name, value: value, priority: priority }); }, /** * Add a "remove" entry to the modification list. * * @param {Number} index index of the property in the rule. * This can be -1 in the case where * the rule does not support setRuleText; * generally for setting properties * on an element's style. * @param {String} name the name of the property to remove */ removeProperty: function(index, name) { this.modifications.push({ type: "remove", name: name }); }, /** * Rename a property. This implementation acts like * |removeProperty|, because |setRuleText| is not available. * * @param {Number} index index of the property in the rule. * This can be -1 in the case where * the rule does not support setRuleText; * generally for setting properties * on an element's style. * @param {String} name current name of the property * @param {String} newName new name of the property */ renameProperty: function(index, name, newName) { this.removeProperty(index, name); }, /** * Enable or disable a property. This implementation acts like * |removeProperty| when disabling, or a no-op when enabling, * because |setRuleText| is not available. * * @param {Number} index index of the property in the rule. * This can be -1 in the case where * the rule does not support setRuleText; * generally for setting properties * on an element's style. * @param {String} name current name of the property * @param {Boolean} isEnabled true if the property should be enabled; * false if it should be disabled */ setPropertyEnabled: function(index, name, isEnabled) { if (!isEnabled) { this.removeProperty(index, name); } }, /** * Create a new property. This implementation does nothing, because * |setRuleText| is not available. * * @param {Number} index index of the property in the rule. * This can be -1 in the case where * the rule does not support setRuleText; * generally for setting properties * on an element's style. * @param {String} name name of the new property * @param {String} value value of the new property * @param {String} priority priority of the new property; either * the empty string or "important" */ createProperty: function(index, name, value, priority) { // Nothing. }, }); /** * 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;