diff --git a/browser/devtools/styleinspector/rule-view.js b/browser/devtools/styleinspector/rule-view.js index 2f3e6eb6c146..c4457382590b 100644 --- a/browser/devtools/styleinspector/rule-view.js +++ b/browser/devtools/styleinspector/rule-view.js @@ -442,7 +442,7 @@ Rule.prototype = { return this._title; } this._title = CssLogic.shortSource(this.sheet); - if (this.domRule.type !== ELEMENT_STYLE) { + if (this.domRule.type !== ELEMENT_STYLE && this.ruleLine > 0) { this._title += ":" + this.ruleLine; } @@ -1076,6 +1076,7 @@ function CssRuleView(aInspector, aDoc, aStore, aPageStyle) { this._buildContextMenu = this._buildContextMenu.bind(this); this._contextMenuUpdate = this._contextMenuUpdate.bind(this); + this._onAddRule = this._onAddRule.bind(this); this._onSelectAll = this._onSelectAll.bind(this); this._onCopy = this._onCopy.bind(this); this._onCopyColor = this._onCopyColor.bind(this); @@ -1125,6 +1126,11 @@ CssRuleView.prototype = { this._contextmenu.addEventListener("popupshowing", this._contextMenuUpdate); this._contextmenu.id = "rule-view-context-menu"; + this.menuitemAddRule = createMenuItem(this._contextmenu, { + label: "ruleView.contextmenu.addRule", + accesskey: "ruleView.contextmenu.addRule.accessKey", + command: this._onAddRule + }); this.menuitemSelectAll = createMenuItem(this._contextmenu, { label: "ruleView.contextmenu.selectAll", accesskey: "ruleView.contextmenu.selectAll.accessKey", @@ -1140,7 +1146,7 @@ CssRuleView.prototype = { accesskey: "ruleView.contextmenu.copyColor.accessKey", command: this._onCopyColor }); - this.menuitemSources= createMenuItem(this._contextmenu, { + this.menuitemSources = createMenuItem(this._contextmenu, { label: "ruleView.contextmenu.showOrigSources", accesskey: "ruleView.contextmenu.showOrigSources.accessKey", command: this._onToggleOrigSources @@ -1354,6 +1360,43 @@ CssRuleView.prototype = { Services.prefs.setBoolPref(PREF_ORIG_SOURCES, !isEnabled); }, + /** + * Add a new rule to the current element. + */ + _onAddRule: function() { + let elementStyle = this._elementStyle; + let element = elementStyle.element; + let rules = elementStyle.rules; + let client = this.inspector.toolbox._target.client; + + if (!client.traits.addNewRule) { + return; + } + + this.pageStyle.addNewRule(element).then(options => { + let newRule = new Rule(elementStyle, options); + elementStyle.rules.push(newRule); + let editor = new RuleEditor(this, newRule); + + // Insert the new rule editor after the inline element rule + if (rules.length <= 1) { + this.element.appendChild(editor.element); + } else { + for (let rule of rules) { + if (rule.selectorText === "element") { + let referenceElement = rule.editor.element.nextSibling; + this.element.insertBefore(editor.element, referenceElement); + break; + } + } + } + + // Focus and make the new rule's selector editable + editor.selectorText.click(); + elementStyle._changed(); + }); + }, + setPageStyle: function(aPageStyle) { this.pageStyle = aPageStyle; }, @@ -1852,6 +1895,9 @@ RuleEditor.prototype = { } } else { sourceLabel.setAttribute("value", this.rule.title); + if (this.rule.ruleLine == -1 && this.rule.domRule.parentStyleSheet) { + sourceLabel.parentNode.setAttribute("unselectable", "true"); + } } let showOrig = Services.prefs.getBoolPref(PREF_ORIG_SOURCES); diff --git a/browser/devtools/styleinspector/test/browser.ini b/browser/devtools/styleinspector/test/browser.ini index 363787de01e1..1f5be910337e 100644 --- a/browser/devtools/styleinspector/test/browser.ini +++ b/browser/devtools/styleinspector/test/browser.ini @@ -42,6 +42,8 @@ support-files = [browser_ruleview_add-property-cancel_03.js] [browser_ruleview_add-property_01.js] [browser_ruleview_add-property_02.js] +[browser_ruleview_add-rule_01.js] +[browser_ruleview_add-rule_02.js] [browser_ruleview_colorpicker-and-image-tooltip_01.js] [browser_ruleview_colorpicker-and-image-tooltip_02.js] [browser_ruleview_colorpicker-appears-on-swatch-click.js] diff --git a/browser/devtools/styleinspector/test/browser_ruleview_add-rule_01.js b/browser/devtools/styleinspector/test/browser_ruleview_add-rule_01.js new file mode 100644 index 000000000000..5c572c4bfb6d --- /dev/null +++ b/browser/devtools/styleinspector/test/browser_ruleview_add-rule_01.js @@ -0,0 +1,89 @@ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the behaviour of adding a new rule to the rule view and the +// various inplace-editor behaviours in the new rule editor + +let PAGE_CONTENT = [ + '', + '
Styled Node
', + 'This is a span', + '

Empty

' +].join("\n"); + +const TEST_DATA = [ + { node: "#testid", expected: "#testid" }, + { node: ".testclass2", expected: ".testclass2" }, + { node: "p", expected: "p" } +]; + +let test = asyncTest(function*() { + yield addTab("data:text/html;charset=utf-8,test rule view add rule"); + + info("Creating the test document"); + content.document.body.innerHTML = PAGE_CONTENT; + + info("Opening the rule-view"); + let {toolbox, inspector, view} = yield openRuleView(); + + info("Iterating over the test data"); + for (let data of TEST_DATA) { + yield runTestData(inspector, view, data); + } +}); + +function* runTestData(inspector, view, data) { + let {node, expected} = data; + info("Selecting the test element"); + yield selectNode(node, inspector); + + info("Waiting for context menu to be shown"); + let onPopup = once(view._contextmenu, "popupshown"); + let win = view.doc.defaultView; + + EventUtils.synthesizeMouseAtCenter(view.element, + {button: 2, type: "contextmenu"}, win); + yield onPopup; + + ok(!view.menuitemAddRule.hidden, "Add rule is visible"); + + info("Waiting for rule view to change"); + let onRuleViewChanged = once(view.element, "CssRuleViewChanged"); + + info("Adding the new rule"); + view.menuitemAddRule.click(); + yield onRuleViewChanged; + view._contextmenu.hidePopup(); + + yield testNewRule(view, expected, 1); + + info("Resetting page content"); + content.document.body.innerHTML = PAGE_CONTENT; +} + +function* testNewRule(view, expected, index) { + let idRuleEditor = getRuleViewRuleEditor(view, index); + let editor = idRuleEditor.selectorText.ownerDocument.activeElement; + is(editor.value, expected, + "Selector editor value is as expected: " + expected); + + info("Entering the escape key"); + EventUtils.synthesizeKey("VK_ESCAPE", {}); + + is(idRuleEditor.selectorText.textContent, expected, + "Selector text value is as expected: " + expected); + + info("Adding new properties to new rule: " + expected) + idRuleEditor.addProperty("font-weight", "bold", ""); + let textProps = idRuleEditor.rule.textProps; + let lastRule = textProps[textProps.length - 1]; + is(lastRule.name, "font-weight", "Last rule name is font-weight"); + is(lastRule.value, "bold", "Last rule value is bold"); +} diff --git a/browser/devtools/styleinspector/test/browser_ruleview_add-rule_02.js b/browser/devtools/styleinspector/test/browser_ruleview_add-rule_02.js new file mode 100644 index 000000000000..ff0c8698b129 --- /dev/null +++ b/browser/devtools/styleinspector/test/browser_ruleview_add-rule_02.js @@ -0,0 +1,78 @@ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the behaviour of adding a new rule to the rule view and editing +// its selector + +let PAGE_CONTENT = [ + '', + '

Styled Node
', + 'This is a span' +].join("\n"); + +let test = asyncTest(function*() { + yield addTab("data:text/html;charset=utf-8,test rule view add rule"); + + info("Creating the test document"); + content.document.body.innerHTML = PAGE_CONTENT; + + info("Opening the rule-view"); + let {toolbox, inspector, view} = yield openRuleView(); + + info("Selecting the test element"); + yield selectNode("#testid", inspector); + + info("Waiting for context menu to be shown"); + let onPopup = once(view._contextmenu, "popupshown"); + let win = view.doc.defaultView; + + EventUtils.synthesizeMouseAtCenter(view.element, + {button: 2, type: "contextmenu"}, win); + yield onPopup; + + ok(!view.menuitemAddRule.hidden, "Add rule is visible"); + + info("Waiting for rule view to change"); + let onRuleViewChanged = once(view.element, "CssRuleViewChanged"); + + info("Adding the new rule"); + view.menuitemAddRule.click(); + yield onRuleViewChanged; + view._contextmenu.hidePopup(); + + yield testEditSelector(view, "span"); + + info("Selecting the modified element"); + yield selectNode("span", inspector); + yield checkModifiedElement(view, "span"); +}); + +function* testEditSelector(view, name) { + info("Test editing existing selector field"); + let idRuleEditor = getRuleViewRuleEditor(view, 1); + let editor = idRuleEditor.selectorText.ownerDocument.activeElement; + + info("Entering a new selector name and committing"); + editor.value = name; + + info("Waiting for rule view to refresh"); + let onRuleViewRefresh = once(view.element, "CssRuleViewRefreshed"); + + info("Entering the commit key"); + EventUtils.synthesizeKey("VK_RETURN", {}); + yield onRuleViewRefresh; + + is(view._elementStyle.rules.length, 2, "Should have 2 rules."); +} + +function* checkModifiedElement(view, name) { + is(view._elementStyle.rules.length, 2, "Should have 2 rules."); + ok(getRuleViewRule(view, name), "Rule with " + name + " selector exists."); +} diff --git a/toolkit/devtools/server/actors/root.js b/toolkit/devtools/server/actors/root.js index e40bc43e4242..c2839a354255 100644 --- a/toolkit/devtools/server/actors/root.js +++ b/toolkit/devtools/server/actors/root.js @@ -124,7 +124,10 @@ RootActor.prototype = { bulk: true, // Whether the style rule actor implements the modifySelector method // that modifies the rule's selector - selectorEditable: true + selectorEditable: true, + // Whether the page style actor implements the addNewRule method that + // adds new rules to the page + addNewRule: true }, /** diff --git a/toolkit/devtools/server/actors/styles.js b/toolkit/devtools/server/actors/styles.js index fe6e6ee84de4..1509a9e0a510 100644 --- a/toolkit/devtools/server/actors/styles.js +++ b/toolkit/devtools/server/actors/styles.js @@ -29,6 +29,9 @@ exports.PSEUDO_ELEMENTS = PSEUDO_ELEMENTS; // 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. @@ -52,6 +55,12 @@ types.addDictType("matchedselector", { status: "number" }); +types.addDictType("appliedStylesReturn", { + entries: "array:appliedstyle", + rules: "array:domstylerule", + sheets: "array:stylesheet" +}); + /** * The PageStyle actor lets the client look at the styles on a page, as * they are applied to a given node. @@ -79,6 +88,9 @@ var PageStyleActor = protocol.ActorClass({ // 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); }, get conn() this.inspector.conn, @@ -279,7 +291,6 @@ var PageStyleActor = protocol.ActorClass({ /** * Get the set of styles that apply to a given 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. @@ -291,46 +302,8 @@ var PageStyleActor = protocol.ActorClass({ */ getApplied: method(function(node, options) { let entries = []; - this.addElementRules(node.rawNode, undefined, options, entries); - - if (options.inherited) { - let parent = this.walker.parentNode(node); - while (parent && parent.rawNode.nodeType != Ci.nsIDOMNode.DOCUMENT_NODE) { - this.addElementRules(parent.rawNode, parent, options, entries); - 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; - entry.matchedSelectors = []; - for (let i = 0; i < selectors.length; i++) { - if (DOMUtils.selectorMatchesElement(element, domRule, i)) { - entry.matchedSelectors.push(selectors[i]); - } - } - - } - } - - 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] - } + return this.getAppliedProps(node, entries, options); }, { request: { node: Arg(0, "domnode"), @@ -338,11 +311,7 @@ var PageStyleActor = protocol.ActorClass({ matchedSelectors: Option(1, "boolean"), filter: Option(1, "string") }, - response: RetVal(types.addDictType("appliedStylesReturn", { - entries: "array:appliedstyle", - rules: "array:domstylerule", - sheets: "array:stylesheet" - })) + response: RetVal("appliedStylesReturn") }), _hasInheritedProps: function(style) { @@ -414,6 +383,66 @@ var PageStyleActor = protocol.ActorClass({ } }, + /** + * Helper function for getApplied and addNewRule 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) { + this.addElementRules(parent.rawNode, parent, options, entries); + 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; + entry.matchedSelectors = []; + for (let i = 0; i < selectors.length; i++) { + if (DOMUtils.selectorMatchesElement(element, domRule, i)) { + entry.matchedSelectors.push(selectors[i]); + } + } + } + } + + 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. */ @@ -516,6 +545,59 @@ var PageStyleActor = protocol.ActorClass({ 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.createElement("style"); + style.setAttribute("type", "text/css"); + document.head.appendChild(style); + this._styleElement = style; + } + + return this._styleElement; + }, + + /** + * Adds a new rule, and returns the new StyleRuleActor. + * @param NodeActor node + * @returns StyleRuleActor of the new rule + */ + addNewRule: method(function(node) { + let style = this.styleElement; + let sheet = style.sheet; + let rawNode = node.rawNode; + + let selector; + if (rawNode.id) { + selector = "#" + rawNode.id; + } else if (rawNode.className) { + selector = "." + rawNode.className; + } else { + selector = rawNode.tagName.toLowerCase(); + } + + let index = sheet.insertRule(selector +" {}", sheet.cssRules.length); + let ruleActor = this._styleRef(sheet.cssRules[index]); + return this.getAppliedProps(node, [{ rule: ruleActor }], + { matchedSelectors: true }); + }, { + request: { + node: Arg(0, "domnode") + }, + response: RetVal("appliedStylesReturn") + }), + }); exports.PageStyleActor = PageStyleActor; @@ -550,12 +632,17 @@ var PageStyleFront = protocol.FrontClass(PageStyleActor, { }); }, { impl: "_getApplied" + }), + + addNewRule: protocol.custom(function(node) { + return this._addNewRule(node).then(ret => { + return ret.entries[0]; + }); + }, { + impl: "_addNewRule" }) }); -// Predeclare the domstylerule actor type -types.addActorType("domstylerule"); - /** * An actor that represents a CSS style object on the protocol. * diff --git a/toolkit/locales/en-US/chrome/global/devtools/styleinspector.properties b/toolkit/locales/en-US/chrome/global/devtools/styleinspector.properties index 91d232d6aec2..3dce179519c1 100644 --- a/toolkit/locales/en-US/chrome/global/devtools/styleinspector.properties +++ b/toolkit/locales/en-US/chrome/global/devtools/styleinspector.properties @@ -104,6 +104,14 @@ ruleView.contextmenu.showCSSSources=Show CSS sources # the rule view context menu "Show CSS sources" entry. ruleView.contextmenu.showCSSSources.accessKey=S +# LOCALIZATION NOTE (ruleView.contextmenu.addRule): Text displayed in the +# rule view context menu for adding a new rule to the element. +ruleView.contextmenu.addRule=Add rule + +# LOCALIZATION NOTE (ruleView.contextmenu.addRule.accessKey): Access key for +# the rule view context menu "Add rule" entry. +ruleView.contextmenu.addRule.accessKey=R + # LOCALIZATION NOTE (computedView.contextmenu.selectAll): Text displayed in the # computed view context menu. computedView.contextmenu.selectAll=Select all