Bug 964255 - Introduces a css declarations parser in the rule-view and fixes background-url edition; r=bgrins

This commit is contained in:
Patrick Brosset 2014-02-03 09:52:09 -06:00
parent e74d99b6d0
commit 93bd77e340
8 changed files with 524 additions and 99 deletions

View File

@ -0,0 +1,153 @@
/* -*- Mode: javascript; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
/* vim: set ts=2 et sw=2 tw=80: */
/* 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 cssTokenizer = require("devtools/sourceeditor/css-tokenizer");
/**
* Returns the string enclosed in quotes
*/
function quoteString(string) {
let hasDoubleQuotes = string.contains('"');
let hasSingleQuotes = string.contains("'");
if (hasDoubleQuotes && !hasSingleQuotes) {
// In this case, no escaping required, just enclose in single-quotes
return "'" + string + "'";
}
// In all other cases, enclose in double-quotes, and escape any double-quote
// that may be in the string
return '"' + string.replace(/"/g, '\"') + '"';
}
/**
* Returns an array of CSS declarations given an string.
* For example, parseDeclarations("width: 1px; height: 1px") would return
* [{name:"width", value: "1px"}, {name: "height", "value": "1px"}]
*
* The input string is assumed to only contain declarations so { and } characters
* will be treated as part of either the property or value, depending where it's
* found.
*
* @param {string} inputString
* An input string of CSS
* @return {Array} an array of objects with the following signature:
* [{"name": string, "value": string, "priority": string}, ...]
*/
function parseDeclarations(inputString) {
let tokens = cssTokenizer(inputString);
let declarations = [{name: "", value: "", priority: ""}];
let current = "", hasBang = false, lastProp;
for (let token of tokens) {
lastProp = declarations[declarations.length - 1];
if (token.tokenType === ":") {
if (!lastProp.name) {
// Set the current declaration name if there's no name yet
lastProp.name = current.trim();
current = "";
hasBang = false;
} else {
// Otherwise, just append ':' to the current value (declaration value
// with colons)
current += ":";
}
} else if (token.tokenType === ";") {
lastProp.value = current.trim();
current = "";
hasBang = false;
declarations.push({name: "", value: "", priority: ""});
} else {
switch(token.tokenType) {
case "IDENT":
if (token.value === "important" && hasBang) {
lastProp.priority = "important";
hasBang = false;
} else {
if (hasBang) {
current += "!";
}
current += token.value;
}
break;
case "WHITESPACE":
current += " ";
break;
case "DIMENSION":
current += token.repr;
break;
case "HASH":
current += "#" + token.value;
break;
case "URL":
current += "url(" + quoteString(token.value) + ")";
break;
case "FUNCTION":
current += token.value + "(";
break;
case ")":
current += token.tokenType;
break;
case "EOF":
break;
case "DELIM":
if (token.value === "!") {
hasBang = true;
} else {
current += token.value;
}
break;
case "STRING":
current += quoteString(token.value);
break;
case "{":
case "}":
current += token.tokenType;
break;
default:
current += token.value;
break;
}
}
}
// Handle whatever trailing properties or values might still be there
if (current) {
if (!lastProp.name) {
// Trailing property found, e.g. p1:v1;p2:v2;p3
lastProp.name = current.trim();
} else {
// Trailing value found, i.e. value without an ending ;
lastProp.value += current.trim();
}
}
// Remove declarations that have neither a name nor a value
declarations = declarations.filter(prop => prop.name || prop.value);
return declarations;
};
exports.parseDeclarations = parseDeclarations;
/**
* Expects a single CSS value to be passed as the input and parses the value
* and priority.
*
* @param {string} value The value from the text editor.
* @return {object} an object with 'value' and 'priority' properties.
*/
function parseSingleValue(value) {
let declaration = parseDeclarations("a: " + value + ";")[0];
return {
value: declaration ? declaration.value : "",
priority: declaration ? declaration.priority : ""
};
};
exports.parseSingleValue = parseSingleValue;

View File

@ -14,7 +14,8 @@ const {ELEMENT_STYLE, PSEUDO_ELEMENTS} = require("devtools/server/actors/styles"
const {gDevTools} = Cu.import("resource:///modules/devtools/gDevTools.jsm", {});
const {Tooltip, SwatchColorPickerTooltip} = require("devtools/shared/widgets/Tooltip");
const {OutputParser} = require("devtools/output-parser");
const { PrefObserver, PREF_ORIG_SOURCES } = require("devtools/styleeditor/utils");
const {PrefObserver, PREF_ORIG_SOURCES} = require("devtools/styleeditor/utils");
const {parseSingleValue, parseDeclarations} = require("devtools/styleinspector/css-parsing-utils");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
@ -582,7 +583,7 @@ Rule.prototype = {
let promise = aModifications.apply().then(() => {
let cssProps = {};
for (let cssProp of parseCSSText(this.style.cssText)) {
for (let cssProp of parseDeclarations(this.style.cssText)) {
cssProps[cssProp.name] = cssProp;
}
@ -692,7 +693,7 @@ Rule.prototype = {
_getTextProperties: function() {
let textProps = [];
let store = this.elementStyle.store;
let props = parseCSSText(this.style.cssText);
let props = parseDeclarations(this.style.cssText);
for (let prop of props) {
let name = prop.name;
if (this.inherited && !domUtils.isInheritedProperty(name)) {
@ -1850,17 +1851,13 @@ RuleEditor.prototype = {
return;
}
// Deal with adding declarations later (once editor has been destroyed).
// If aValue is just a name, will make a new property with empty value.
this.multipleAddedProperties = parseCSSText(aValue);
if (!this.multipleAddedProperties.length) {
this.multipleAddedProperties = [{
name: aValue,
value: "",
priority: ""
}];
}
// parseDeclarations allows for name-less declarations, but in the present
// case, we're creating a new declaration, it doesn't make sense to accept
// these entries
this.multipleAddedProperties = parseDeclarations(aValue).filter(d => d.name);
// Blur the editor field now and deal with adding declarations later when
// the field gets destroyed (see _newPropertyDestroy)
this.editor.input.blur();
},
@ -2263,17 +2260,16 @@ TextPropertyEditor.prototype = {
if (aValue.trim() === "") {
this.remove();
} else {
// Adding multiple rules inside of name field overwrites the current
// property with the first, then adds any more onto the property list.
let properties = parseCSSText(aValue);
if (properties.length > 0) {
this.prop.setName(properties[0].name);
this.prop.setValue(properties[0].value, properties[0].priority);
let properties = parseDeclarations(aValue);
this.ruleEditor.addProperties(properties.slice(1), this.prop);
} else {
this.prop.setName(aValue);
if (properties.length) {
this.prop.setName(properties[0].name);
if (properties.length > 1) {
this.prop.setValue(properties[0].value, properties[0].priority);
this.ruleEditor.addProperties(properties.slice(1), this.prop);
}
}
}
}
@ -2320,7 +2316,7 @@ TextPropertyEditor.prototype = {
let {propertiesToAdd,firstValue} = this._getValueAndExtraProperties(aValue);
// First, set this property value (common case, only modified a property)
let val = parseCSSValue(firstValue);
let val = parseSingleValue(firstValue);
this.prop.setValue(val.value, val.priority);
this.removeOnRevert = false;
this.committed.value = this.prop.value;
@ -2356,36 +2352,31 @@ TextPropertyEditor.prototype = {
* firstValue: A string containing a simple value, like
* "red" or "100px!important"
* propertiesToAdd: An array with additional properties, following the
* parseCSSText format of {name,value,priority}
* parseDeclarations format of {name,value,priority}
*/
_getValueAndExtraProperties: function(aValue) {
// The inplace editor will prevent manual typing of multiple properties,
// but we need to deal with the case during a paste event.
// Adding multiple properties inside of value editor sets value with the
// first, then adds any more onto the property list (below this property).
let properties = parseCSSText(aValue);
let propertiesToAdd = [];
let firstValue = aValue;
let propertiesToAdd = [];
if (properties.length > 0) {
// If text like "red; width: 1px;" was entered in, handle this as two
// separate properties (setting value here to red and adding a new prop).
let propertiesNoName = parseCSSText("a:" + aValue);
let enteredValueFirst = propertiesNoName.length > properties.length;
let properties = parseDeclarations(aValue);
let firstProp = properties[0];
propertiesToAdd = properties.slice(1);
if (enteredValueFirst) {
firstProp = propertiesNoName[0];
propertiesToAdd = propertiesNoName.slice(1);
// Check to see if the input string can be parsed as multiple properties
if (properties.length) {
// Get the first property value (if any), and any remaining properties (if any)
if (!properties[0].name && properties[0].value) {
firstValue = properties[0].value;
propertiesToAdd = properties.slice(1);
}
// In some cases, the value could be a property:value pair itself.
// Join them as one value string and append potentially following properties
else if (properties[0].name && properties[0].value) {
firstValue = properties[0].name + ": " + properties[0].value;
propertiesToAdd = properties.slice(1);
}
// If "red; width: 1px", then set value to "red"
// If "color: red; width: 1px;", then set value to "color: red;"
firstValue = enteredValueFirst ?
firstProp.value + "!" + firstProp.priority :
firstProp.name + ": " + firstProp.value + "!" + firstProp.priority;
}
return {
@ -2395,7 +2386,7 @@ TextPropertyEditor.prototype = {
},
_applyNewValue: function(aValue) {
let val = parseCSSValue(aValue);
let val = parseSingleValue(aValue);
// Any property should be removed if has an empty value.
if (val.value.trim() === "") {
this.remove();
@ -2419,7 +2410,7 @@ TextPropertyEditor.prototype = {
return;
}
let val = parseCSSValue(aValue);
let val = parseSingleValue(aValue);
// Live previewing the change without committing just yet, that'll be done in _onValueDone
// If it was not a valid value, apply an empty string to reset the live preview
@ -2439,7 +2430,7 @@ TextPropertyEditor.prototype = {
isValid: function(aValue) {
let name = this.prop.name;
let value = typeof aValue == "undefined" ? this.prop.value : aValue;
let val = parseCSSValue(value);
let val = parseSingleValue(value);
let style = this.doc.createElementNS(HTML_NS, "div").style;
let prefs = Services.prefs;
@ -2605,64 +2596,14 @@ function throttle(func, wait, scope) {
};
}
/**
* Pull priority (!important) out of the value provided by a
* value editor.
*
* @param {string} aValue
* The value from the text editor.
* @return {object} an object with 'value' and 'priority' properties.
*/
function parseCSSValue(aValue) {
let pieces = aValue.split("!", 2);
return {
value: pieces[0].trim(),
priority: (pieces.length > 1 ? pieces[1].trim() : "")
};
}
/**
* Return an array of CSS properties given an input string
* For example, parseCSSText("width: 1px; height: 1px") would return
* [{name:"width", value: "1px"}, {name: "height", "value": "1px"}]
*
* @param {string} aCssText
* An input string of CSS
* @return {Array} an array of objects with the following signature:
* [{"name": string, "value": string, "priority": string}, ...]
*/
function parseCSSText(aCssText) {
let lines = aCssText.match(CSS_LINE_RE);
let props = [];
[].forEach.call(lines, (line, i) => {
let [, name, value, priority] = CSS_PROP_RE.exec(line) || [];
// If this is ending with an unfinished line, add it onto the end
// with an empty value
if (!name && line && i > 0) {
name = line;
}
if (name) {
props.push({
name: name.trim(),
value: value || "",
priority: priority || ""
});
}
});
return props;
}
/**
* Event handler that causes a blur on the target if the input has
* multiple CSS properties as the value.
*/
function blurOnMultipleProperties(e) {
setTimeout(() => {
if (parseCSSText(e.target.value).length) {
let props = parseDeclarations(e.target.value);
if (props.length > 1) {
e.target.blur();
}
}, 0);

View File

@ -6,6 +6,8 @@ let doc;
let ruleWindow;
let ruleView;
let inspector;
let TEST_URL = 'url("http://example.com/browser/browser/devtools/' +
'styleinspector/test/test-image.png")';
function startTest()
{
@ -140,7 +142,7 @@ function testEditProperty()
let value = idRuleEditor.rule.domRule._rawStyle().getPropertyValue("border-color");
is(value, "red", "border-color should have been set.");
is(propEditor.isValid(), true, "red should be a valid entry");
finishTest();
testEditPropertyWithColon();
}));
});
@ -159,6 +161,43 @@ function testEditProperty()
ruleWindow);
}
function testEditPropertyWithColon()
{
let idRuleEditor = ruleView.element.children[1]._ruleEditor;
let propEditor = idRuleEditor.rule.textProps[0].editor;
waitForEditorFocus(propEditor.element, function onNewElement(aEditor) {
is(inplaceEditor(propEditor.nameSpan), aEditor, "Next focused editor should be the name editor.");
let input = aEditor.input;
waitForEditorFocus(propEditor.element, function onNewName(aEditor) {
promiseDone(expectRuleChange(idRuleEditor.rule).then(() => {
input = aEditor.input;
is(inplaceEditor(propEditor.valueSpan), aEditor, "Focus should have moved to the value.");
waitForEditorBlur(aEditor, function() {
promiseDone(expectRuleChange(idRuleEditor.rule).then(() => {
let value = idRuleEditor.rule.domRule._rawStyle().getPropertyValue("background-image");
is(value, TEST_URL, "background-image should have been set.");
is(propEditor.isValid(), true, "the test URL should be a valid entry");
finishTest();
}));
});
for (let ch of (TEST_URL + ";")) {
EventUtils.sendChar(ch, ruleWindow);
}
}));
});
for (let ch of "background-image:") {
EventUtils.sendChar(ch, ruleWindow);
}
});
EventUtils.synthesizeMouse(propEditor.nameSpan, 32, 1,
{ },
ruleWindow);
}
function finishTest()
{
inspector = ruleWindow = ruleView = null;

View File

@ -28,13 +28,15 @@ function selectNewElement()
let newElement = doc.createElement("div");
newElement.textContent = "Test Element";
doc.body.appendChild(newElement);
inspector.selection.setNode(newElement);
inspector.selection.setNode(newElement, "test");
let def = promise.defer();
ruleView.element.addEventListener("CssRuleViewRefreshed", function changed() {
ruleView.element.removeEventListener("CssRuleViewRefreshed", changed);
elementRuleEditor = ruleView.element.children[0]._ruleEditor;
def.resolve();
});
return def.promise;
}

View File

@ -5,3 +5,4 @@
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
BROWSER_CHROME_MANIFESTS += ['browser.ini']
XPCSHELL_TESTS_MANIFESTS += ['unit/xpcshell.ini']

View File

@ -0,0 +1,206 @@
/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
/* vim: set ts=2 et sw=2 tw=80: */
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
const Cu = Components.utils;
Cu.import("resource://gre/modules/devtools/Loader.jsm");
const {parseDeclarations} = devtools.require("devtools/styleinspector/css-parsing-utils");
const TEST_DATA = [
// Simple test
{
input: "p:v;",
expected: [{name: "p", value: "v", priority: ""}]
},
// Simple test
{
input: "this:is;a:test;",
expected: [
{name: "this", value: "is", priority: ""},
{name: "a", value: "test", priority: ""}
]
},
// Test a single declaration with semi-colon
{
input: "name:value;",
expected: [{name: "name", value: "value", priority: ""}]
},
// Test a single declaration without semi-colon
{
input: "name:value",
expected: [{name: "name", value: "value", priority: ""}]
},
// Test multiple declarations separated by whitespaces and carriage returns and tabs
{
input: "p1 : v1 ; \t\t \n p2:v2; \n\n\n\n\t p3 : v3;",
expected: [
{name: "p1", value: "v1", priority: ""},
{name: "p2", value: "v2", priority: ""},
{name: "p3", value: "v3", priority: ""},
]
},
// Test simple priority
{
input: "p1: v1; p2: v2 !important;",
expected: [
{name: "p1", value: "v1", priority: ""},
{name: "p2", value: "v2", priority: "important"}
]
},
// Test simple priority
{
input: "p1: v1 !important; p2: v2",
expected: [
{name: "p1", value: "v1", priority: "important"},
{name: "p2", value: "v2", priority: ""}
]
},
// Test simple priority
{
input: "p1: v1 ! important; p2: v2 ! important;",
expected: [
{name: "p1", value: "v1", priority: "important"},
{name: "p2", value: "v2", priority: "important"}
]
},
// Test invalid priority
{
input: "p1: v1 important;",
expected: [
{name: "p1", value: "v1 important", priority: ""}
]
},
// Test various types of background-image urls
{
input: "background-image: url(../../relative/image.png)",
expected: [{name: "background-image", value: "url(\"../../relative/image.png\")", priority: ""}]
},
{
input: "background-image: url(http://site.com/test.png)",
expected: [{name: "background-image", value: "url(\"http://site.com/test.png\")", priority: ""}]
},
{
input: "background-image: url(wow.gif)",
expected: [{name: "background-image", value: "url(\"wow.gif\")", priority: ""}]
},
// Test that urls with :;{} characters in them are parsed correctly
{
input: "background: red url(\"http://site.com/image{}:;.png?id=4#wat\") repeat top right",
expected: [
{name: "background", value: "red url(\"http://site.com/image{}:;.png?id=4#wat\") repeat top right", priority: ""}
]
},
// Test that an empty string results in an empty array
{input: "", expected: []},
// Test that a string comprised only of whitespaces results in an empty array
{input: " \n \n \n \n \t \t\t\t ", expected: []},
// Test that a null input throws an exception
{input: null, throws: true},
// Test that a undefined input throws an exception
{input: undefined, throws: true},
// Test that :;{} characters in quoted content are not parsed as multiple declarations
{
input: "content: \";color:red;}selector{color:yellow;\"",
expected: [
{name: "content", value: "\";color:red;}selector{color:yellow;\"", priority: ""}
]
},
// Test that rules aren't parsed, just declarations. So { and } found after a
// property name should be part of the property name, same for values.
{
input: "body {color:red;} p {color: blue;}",
expected: [
{name: "body {color", value: "red", priority: ""},
{name: "} p {color", value: "blue", priority: ""},
{name: "}", value: "", priority: ""}
]
},
// Test unbalanced : and ;
{
input: "color :red : font : arial;",
expected : [
{name: "color", value: "red : font : arial", priority: ""}
]
},
{input: "background: red;;;;;", expected: [{name: "background", value: "red", priority: ""}]},
{input: "background:;", expected: [{name: "background", value: "", priority: ""}]},
{input: ";;;;;", expected: []},
{input: ":;:;", expected: []},
// Test name only
{input: "color", expected: [
{name: "color", value: "", priority: ""}
]},
// Test trailing name without :
{input: "color:blue;font", expected: [
{name: "color", value: "blue", priority: ""},
{name: "font", value: "", priority: ""}
]},
// Test trailing name with :
{input: "color:blue;font:", expected: [
{name: "color", value: "blue", priority: ""},
{name: "font", value: "", priority: ""}
]},
// Test leading value
{input: "Arial;color:blue;", expected: [
{name: "", value: "Arial", priority: ""},
{name: "color", value: "blue", priority: ""}
]},
// Test hex colors
{input: "color: #333", expected: [{name: "color", value: "#333", priority: ""}]},
{input: "color: #456789", expected: [{name: "color", value: "#456789", priority: ""}]},
{input: "wat: #XYZ", expected: [{name: "wat", value: "#XYZ", priority: ""}]},
// Test string/url quotes escaping
{input: "content: \"this is a 'string'\"", expected: [{name: "content", value: "\"this is a 'string'\"", priority: ""}]},
{input: 'content: "this is a \\"string\\""', expected: [{name: "content", value: '\'this is a "string"\'', priority: ""}]},
{input: "content: 'this is a \"string\"'", expected: [{name: "content", value: '\'this is a "string"\'', priority: ""}]},
{input: "content: 'this is a \\'string\\'", expected: [{name: "content", value: '"this is a \'string\'"', priority: ""}]},
{input: "content: 'this \\' is a \" really strange string'", expected: [{name: "content", value: '"this \' is a \" really strange string"', priority: ""}]},
{
input: "content: \"a not s\\\
o very long title\"",
expected: [
{name: "content", value: '"a not s\
o very long title"', priority: ""}
]
}
];
function run_test() {
for (let test of TEST_DATA) {
do_print("Test input string " + test.input);
let output;
try {
output = parseDeclarations(test.input);
} catch (e) {
do_print("parseDeclarations threw an exception with the given input string");
if (test.throws) {
do_print("Exception expected");
do_check_true(true);
} else {
do_print("Exception unexpected\n" + e);
do_check_true(false);
}
}
if (output) {
assertOutput(output, test.expected);
}
}
}
function assertOutput(actual, expected) {
if (actual.length === expected.length) {
for (let i = 0; i < expected.length; i ++) {
do_check_true(!!actual[i]);
do_print("Check that the output item has the expected name, value and priority");
do_check_eq(expected[i].name, actual[i].name);
do_check_eq(expected[i].value, actual[i].value);
do_check_eq(expected[i].priority, actual[i].priority);
}
} else {
for (let prop of actual) {
do_print("Actual output contained: {name: "+prop.name+", value: "+prop.value+", priority: "+prop.priority+"}");
}
do_check_eq(actual.length, expected.length);
}
}

View File

@ -0,0 +1,76 @@
/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
/* vim: set ts=2 et sw=2 tw=80: */
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
const Cu = Components.utils;
Cu.import("resource://gre/modules/devtools/Loader.jsm");
const {parseSingleValue} = devtools.require("devtools/styleinspector/css-parsing-utils");
const TEST_DATA = [
{input: null, throws: true},
{input: undefined, throws: true},
{input: "", expected: {value: "", priority: ""}},
{input: " \t \t \n\n ", expected: {value: "", priority: ""}},
{input: "blue", expected: {value: "blue", priority: ""}},
{input: "blue !important", expected: {value: "blue", priority: "important"}},
{input: "blue!important", expected: {value: "blue", priority: "important"}},
{input: "blue ! important", expected: {value: "blue", priority: "important"}},
{input: "blue ! important", expected: {value: "blue", priority: "important"}},
{input: "blue !", expected: {value: "blue", priority: ""}},
{input: "blue !mportant", expected: {value: "blue !mportant", priority: ""}},
{input: " blue !important ", expected: {value: "blue", priority: "important"}},
{
input: "url(\"http://url.com/whyWouldYouDoThat!important.png\") !important",
expected: {
value: "url(\"http://url.com/whyWouldYouDoThat!important.png\")",
priority: "important"
}
},
{
input: "url(\"http://url.com/whyWouldYouDoThat!important.png\")",
expected: {
value: "url(\"http://url.com/whyWouldYouDoThat!important.png\")",
priority: ""
}
},
{
input: "\"content!important\" !important",
expected: {
value: "\"content!important\"",
priority: "important"
}
},
{
input: "\"content!important\"",
expected: {
value: "\"content!important\"",
priority: ""
}
}
];
function run_test() {
for (let test of TEST_DATA) {
do_print("Test input value " + test.input);
try {
let output = parseSingleValue(test.input);
assertOutput(output, test.expected);
} catch (e) {
do_print("parseSingleValue threw an exception with the given input value");
if (test.throws) {
do_print("Exception expected");
do_check_true(true);
} else {
do_print("Exception unexpected\n" + e);
do_check_true(false);
}
}
}
}
function assertOutput(actual, expected) {
do_print("Check that the output has the expected value and priority");
do_check_eq(expected.value, actual.value);
do_check_eq(expected.priority, actual.priority);
}

View File

@ -0,0 +1,7 @@
[DEFAULT]
head =
tail =
firefox-appdir = browser
[test_parseDeclarations.js]
[test_parseSingleValue.js]