Browse Source

Add support for keyframes and font-face at-rules. Fixes #276

pull/803/head
Artur Arseniev 9 years ago
parent
commit
571c69cbf7
  1. 3
      src/canvas/view/CanvasView.js
  2. 31
      src/code_manager/model/CssGenerator.js
  3. 69
      src/css_composer/model/CssRule.js
  4. 12
      src/css_composer/view/CssGroupRuleView.js
  5. 45
      src/css_composer/view/CssRulesView.js
  6. 4
      src/dom_components/view/ComponentTextNodeView.js
  7. 134
      src/parser/model/ParserCss.js
  8. 6
      test/specs/css_composer/e2e/CssComposer.js
  9. 27
      test/specs/css_composer/model/CssModels.js
  10. 41
      test/specs/parser/model/ParserCss.js

3
src/canvas/view/CanvasView.js

@ -1,10 +1,11 @@
import { on, off } from 'utils/mixins';
const FrameView = require('./FrameView'); const FrameView = require('./FrameView');
const $ = Backbone.$; const $ = Backbone.$;
module.exports = Backbone.View.extend({ module.exports = Backbone.View.extend({
initialize(o) { initialize(o) {
_.bindAll(this, 'renderBody', 'onFrameScroll', 'clearOff'); _.bindAll(this, 'renderBody', 'onFrameScroll', 'clearOff');
window.onscroll = this.clearOff; on(window, 'scroll resize', this.clearOff);
this.config = o.config || {}; this.config = o.config || {};
this.em = this.config.em || {}; this.em = this.config.em || {};
this.ppfx = this.config.pStylePrefix || ''; this.ppfx = this.config.pStylePrefix || '';

31
src/code_manager/model/CssGenerator.js

@ -43,19 +43,18 @@ module.exports = require('backbone').Model.extend({
if (cssc) { if (cssc) {
const rules = cssc.getAll(); const rules = cssc.getAll();
const mediaRules = {}; const atRules = {};
const dump = []; const dump = [];
rules.each(rule => { rules.each(rule => {
const media = rule.get('mediaText'); const atRule = rule.getAtRule();
// If media is setted, I'll render it later if (atRule) {
if (media) { const mRules = atRules[atRule];
const mRules = mediaRules[media];
if (mRules) { if (mRules) {
mRules.push(rule); mRules.push(rule);
} else { } else {
mediaRules[media] = [rule]; atRules[atRule] = [rule];
} }
return; return;
} }
@ -63,14 +62,14 @@ module.exports = require('backbone').Model.extend({
code += this.buildFromRule(rule, dump); code += this.buildFromRule(rule, dump);
}); });
// Get media rules // Get at-rules
for (let media in mediaRules) { for (let atRule in atRules) {
let rulesStr = ''; let rulesStr = '';
const mRules = mediaRules[media]; const mRules = atRules[atRule];
mRules.forEach(rule => (rulesStr += this.buildFromRule(rule, dump))); mRules.forEach(rule => (rulesStr += this.buildFromRule(rule, dump)));
if (rulesStr) { if (rulesStr) {
code += `@media ${media}{${rulesStr}}`; code += `${atRule}{${rulesStr}}`;
} }
} }
@ -87,8 +86,9 @@ module.exports = require('backbone').Model.extend({
*/ */
buildFromRule(rule, dump) { buildFromRule(rule, dump) {
let result = ''; let result = '';
const selectorStr = rule.selectorsToString();
const selectorStrNoAdd = rule.selectorsToString({ skipAdd: 1 }); const selectorStrNoAdd = rule.selectorsToString({ skipAdd: 1 });
const selectorsAdd = rule.get('selectorsAdd');
const singleAtRule = rule.get('singleAtRule');
let found; let found;
// This will not render a rule if there is no its component // This will not render a rule if there is no its component
@ -99,12 +99,9 @@ module.exports = require('backbone').Model.extend({
} }
}); });
if ((selectorStrNoAdd && found) || rule.get('selectorsAdd')) { if ((selectorStrNoAdd && found) || selectorsAdd || singleAtRule) {
const style = rule.styleToString(); const block = rule.getDeclaration();
block && (result += block);
if (style) {
result += `${selectorStr}{${style}}`;
}
} else { } else {
dump.push(rule); dump.push(rule);
} }

69
src/css_composer/model/CssRule.js

@ -23,6 +23,13 @@ module.exports = Backbone.Model.extend(Styleable).extend({
// Indicates if the rule is stylable // Indicates if the rule is stylable
stylable: true, stylable: true,
// Type of at-rule, eg. 'media', 'font-face', etc.
atRuleType: '',
// This particolar property is used only on at-rules, like 'page' or
// 'font-face', where the block containes only style declarations
singleAtRule: 0,
// If true, sets '!important' on all properties // If true, sets '!important' on all properties
// You can use an array to specify properties to set important // You can use an array to specify properties to set important
// Used in view // Used in view
@ -47,6 +54,19 @@ module.exports = Backbone.Model.extend(Styleable).extend({
this.set('selectors', new Selectors(selectors)); this.set('selectors', new Selectors(selectors));
}, },
/**
* Returns an at-rule statement if possible, eg. '@media (...)', '@keyframes'
* @return {string}
*/
getAtRule() {
const type = this.get('atRuleType');
const condition = this.get('mediaText');
// Avoid breaks with the last condition
const typeStr = type ? `@${type}` : condition ? '@media' : '';
return typeStr + (condition && typeStr ? ` ${condition}` : '');
},
/** /**
* Return selectors fo the rule as a string * Return selectors fo the rule as a string
* @return {string} * @return {string}
@ -63,22 +83,36 @@ module.exports = Backbone.Model.extend(Styleable).extend({
}, },
/** /**
* Returns CSS string of the rule * Get declaration block
* @param {Object} [opts={}] Options * @param {Object} [opts={}] Options
* @return {string} * @return {string}
*/ */
toCSS(opts = {}) { getDeclaration(opts = {}) {
let result = ''; let result = '';
const media = this.get('mediaText');
const style = this.styleToString(opts);
const selectors = this.selectorsToString(); const selectors = this.selectorsToString();
const style = this.styleToString(opts);
const singleAtRule = this.get('singleAtRule');
if (selectors && style) { if ((selectors || singleAtRule) && style) {
result = `${selectors}{${style}}`; result = singleAtRule ? style : `${selectors}{${style}}`;
} }
if (media && result) { return result;
result = `@media ${media}{${result}}`; },
/**
* Returns CSS string of the rule
* @param {Object} [opts={}] Options
* @return {string}
*/
toCSS(opts = {}) {
let result = '';
const atRule = this.getAtRule();
const block = this.getDeclaration(opts);
block && (result = block);
if (atRule && result) {
result = `${atRule}{${result}}`;
} }
return result; return result;
@ -93,11 +127,11 @@ module.exports = Backbone.Model.extend(Styleable).extend({
* @return {Boolean} * @return {Boolean}
* @private * @private
*/ */
compare(selectors, state, width, ruleProps) { compare(selectors, state, width, ruleProps = {}) {
var otherRule = ruleProps || {};
var st = state || ''; var st = state || '';
var wd = width || ''; var wd = width || '';
var selectorsAdd = otherRule.selectorsAdd || ''; var selectorsAdd = ruleProps.selectorsAdd || '';
var atRuleType = ruleProps.atRuleType || '';
var cId = 'cid'; var cId = 'cid';
//var a1 = _.pluck(selectors.models || selectors, cId); //var a1 = _.pluck(selectors.models || selectors, cId);
//var a2 = _.pluck(this.get('selectors').models, cId); //var a2 = _.pluck(this.get('selectors').models, cId);
@ -117,11 +151,14 @@ module.exports = Backbone.Model.extend(Styleable).extend({
if (re === 0) return f; if (re === 0) return f;
} }
if (this.get('state') !== st) return f; if (
this.get('state') !== st ||
if (this.get('mediaText') !== wd) return f; this.get('mediaText') !== wd ||
this.get('selectorsAdd') !== selectorsAdd ||
if (this.get('selectorsAdd') !== selectorsAdd) return f; this.get('atRuleType') !== atRuleType
) {
return f;
}
return true; return true;
} }

12
src/css_composer/view/CssGroupRuleView.js

@ -0,0 +1,12 @@
module.exports = require('./CssRuleView').extend({
_createElement: function(tagName) {
return document.createTextNode('');
},
render() {
const model = this.model;
const important = model.get('important');
this.el.textContent = model.getDeclaration({ important });
return this;
}
});

45
src/css_composer/view/CssRulesView.js

@ -1,9 +1,10 @@
var Backbone = require('backbone'); const CssRuleView = require('./CssRuleView');
var CssRuleView = require('./CssRuleView'); const CssGroupRuleView = require('./CssGroupRuleView');
module.exports = Backbone.View.extend({ module.exports = require('backbone').View.extend({
initialize(o) { initialize(o) {
const config = o.config || {}; const config = o.config || {};
this.atRules = {};
this.config = config; this.config = config;
this.em = config.em; this.em = config.em;
this.pfx = config.stylePrefix || ''; this.pfx = config.stylePrefix || '';
@ -32,20 +33,44 @@ module.exports = Backbone.View.extend({
addToCollection(model, fragmentEl) { addToCollection(model, fragmentEl) {
var fragment = fragmentEl || null; var fragment = fragmentEl || null;
var viewObject = CssRuleView; var viewObject = CssRuleView;
var config = this.config;
let rendered, view;
const opts = { model, config };
var view = new viewObject({ // I have to render keyframes of the same name together
model, // Unfortunately at the moment I didn't find the way of appending them
config: this.config // if not staticly, via appendData
}); if (model.get('atRuleType') == 'keyframes') {
var rendered = view.render().el; const atRule = model.getAtRule();
let atRuleEl = this.atRules[atRule];
if (fragment) fragment.appendChild(rendered); if (!atRuleEl) {
else this.$el.append(rendered); const styleEl = document.createElement('style');
atRuleEl = document.createTextNode('');
styleEl.appendChild(document.createTextNode(`${atRule}{`));
styleEl.appendChild(atRuleEl);
styleEl.appendChild(document.createTextNode(`}`));
this.atRules[atRule] = atRuleEl;
rendered = styleEl;
}
view = new CssGroupRuleView(opts);
atRuleEl.appendData(view.render().el.textContent);
} else {
view = new CssRuleView(opts);
rendered = view.render().el;
}
if (rendered) {
if (fragment) fragment.appendChild(rendered);
else this.$el.append(rendered);
}
return rendered; return rendered;
}, },
render() { render() {
this.atRules = {};
const $el = this.$el; const $el = this.$el;
const frag = document.createDocumentFragment(); const frag = document.createDocumentFragment();
$el.empty(); $el.empty();

4
src/dom_components/view/ComponentTextNodeView.js

@ -1,3 +1 @@
var Backbone = require('backbone'); module.exports = require('backbone').View.extend({});
module.exports = Backbone.View.extend({});

134
src/parser/model/ParserCss.js

@ -1,3 +1,21 @@
import { keys } from 'underscore';
// At-rules
// https://developer.mozilla.org/it/docs/Web/API/CSSRule#Type_constants
const atRules = {
4: 'media',
5: 'font-face',
6: 'page',
7: 'keyframes',
11: 'counter-style',
12: 'supports',
13: 'document',
14: 'font-feature-values',
15: 'viewport'
};
const atRuleKeys = keys(atRules);
const singleAtRules = ['5', '6', '11', '15'];
module.exports = config => ({ module.exports = config => ({
/** /**
* Parse selector string to array. * Parse selector string to array.
@ -14,7 +32,7 @@ module.exports = config => ({
* //add: ['.test2 .test3'] * //add: ['.test2 .test3']
* //} * //}
*/ */
parseSelector(str) { parseSelector(str = '') {
var add = []; var add = [];
var result = []; var result = [];
var sels = str.split(','); var sels = str.split(',');
@ -35,55 +53,89 @@ module.exports = config => ({
}; };
}, },
/**
* Parse style declarations of the node
* @param {CSSRule} node
* @return {Object}
*/
parseStyle(node) {
const stl = node.style;
const style = {};
for (var i = 0, len = stl.length; i < len; i++) {
const propName = stl[i];
const propValue = stl.getPropertyValue(propName);
const important = stl.getPropertyPriority(propName);
style[propName] = `${propValue}${important ? ` !${important}` : ''}`;
}
return style;
},
/**
* Get the condition when possible
* @param {CSSRule} node
* @return {string}
*/
parseCondition(node) {
const condition =
node.conditionText ||
(node.media && node.media.mediaText) ||
node.name ||
node.selectorText ||
'';
return condition.trim();
},
/** /**
* Fetch data from node * Fetch data from node
* @param {StyleSheet|CSSMediaRule} el * @param {StyleSheet|CSSRule} el
* @return {Array<Object>} * @return {Array<Object>}
*/ */
parseNode(el) { parseNode(el) {
var result = []; var result = [];
var nodes = el.cssRules; var nodes = el.cssRules || [];
for (var i = 0, len = nodes.length; i < len; i++) { for (var i = 0, len = nodes.length; i < len; i++) {
var node = nodes[i]; const node = nodes[i];
var sels = node.selectorText; const type = node.type.toString();
var selsAdd = []; let singleAtRule = 0;
let atRuleType = '';
// It's a CSSMediaRule let condition = '';
if (node.cssRules) { // keyText is for CSSKeyframeRule
let sels = node.selectorText || node.keyText;
const isSingleAtRule = singleAtRules.indexOf(type) >= 0;
// Check if the node is an at-rule
if (isSingleAtRule) {
singleAtRule = 1;
atRuleType = atRules[type];
condition = this.parseCondition(node);
} else if (atRuleKeys.indexOf(type) >= 0) {
var subRules = this.parseNode(node); var subRules = this.parseNode(node);
var mediaText = node.media.mediaText; condition = this.parseCondition(node);
for (var s = 0, lens = subRules.length; s < lens; s++) { for (var s = 0, lens = subRules.length; s < lens; s++) {
var subRule = subRules[s]; var subRule = subRules[s];
subRule.mediaText = mediaText ? mediaText.trim() : ''; condition && (subRule.mediaText = condition);
subRule.atRuleType = atRules[type];
} }
result = result.concat(subRules); result = result.concat(subRules);
} }
if (!sels) continue; if (!sels && !isSingleAtRule) continue;
const style = this.parseStyle(node);
var selsParsed = this.parseSelector(sels); const selsParsed = this.parseSelector(sels);
const selsAdd = selsParsed.add;
sels = selsParsed.result; sels = selsParsed.result;
selsAdd = selsParsed.add;
// Create style object from the big one let lastRule;
var stl = node.style;
var style = {};
for (var j = 0, len2 = stl.length; j < len2; j++) {
const propName = stl[j];
const propValue = stl.getPropertyValue(propName);
const important = stl.getPropertyPriority(propName);
style[propName] = `${propValue}${important ? ` !${important}` : ''}`;
}
var lastRule = '';
// For each group of selectors // For each group of selectors
for (var k = 0, len3 = sels.length; k < len3; k++) { for (var k = 0, len3 = sels.length; k < len3; k++) {
var selArr = sels[k]; var selArr = sels[k];
var model = {}; var model = {};
singleAtRule && (model.singleAtRule = singleAtRule);
atRuleType && (model.atRuleType = atRuleType);
//Isolate state from selector //Isolate state from selector
var stateArr = selArr[selArr.length - 1].split(/:(.+)/); var stateArr = selArr[selArr.length - 1].split(/:(.+)/);
@ -106,13 +158,18 @@ module.exports = config => ({
if (lastRule) { if (lastRule) {
lastRule.selectorsAdd = selsAddStr; lastRule.selectorsAdd = selsAddStr;
} else { } else {
result.push({ const model = {
selectors: [], selectors: [],
selectorsAdd: selsAddStr, selectorsAdd: selsAddStr,
style style
}); };
singleAtRule && (model.singleAtRule = singleAtRule);
atRuleType && (model.atRuleType = atRuleType);
condition && (model.mediaText = condition);
result.push(model);
} }
} }
// console.log('LAST PUSH', result[result.length - 1]);
} }
return result; return result;
@ -124,22 +181,15 @@ module.exports = config => ({
* @return {Object|Array<Object>} * @return {Object|Array<Object>}
*/ */
parse(str) { parse(str) {
var el = document.createElement('style'); const el = document.createElement('style');
/*
el.innerHTML = ".cssClass {border: 2px solid black; background-color: blue;} " +
".red, .red2 {color:red; padding:5px} .test1.red {color:black} .red:hover{color: blue} " +
"@media screen and (min-width: 480px){ .red{color:white} }";
*/
el.innerHTML = str; el.innerHTML = str;
// There is no .sheet without adding it to the <head> // There is no .sheet before adding it to the <head>
document.head.appendChild(el); document.head.appendChild(el);
var sheet = el.sheet; const sheet = el.sheet;
document.head.removeChild(el); document.head.removeChild(el);
var result = this.parseNode(sheet); const result = this.parseNode(sheet);
if (result.length == 1) result = result[0]; return result.length == 1 ? result[0] : result;
return result;
} }
}); });

6
test/specs/css_composer/e2e/CssComposer.js

@ -140,6 +140,8 @@ module.exports = {
protected: false protected: false
} }
], ],
atRuleType: '',
singleAtRule: 0,
important: 0, important: 0,
selectorsAdd: '', selectorsAdd: '',
state: '', state: '',
@ -178,6 +180,8 @@ module.exports = {
rule1Out = JSON.parse(JSON.stringify(rule1Out)); rule1Out = JSON.parse(JSON.stringify(rule1Out));
rule2Out = JSON.parse(JSON.stringify(rule2Out)); rule2Out = JSON.parse(JSON.stringify(rule2Out));
var rule1Result = { var rule1Result = {
atRuleType: '',
singleAtRule: 0,
important: 0, important: 0,
mediaText: '', mediaText: '',
selectors: [], selectors: [],
@ -190,6 +194,8 @@ module.exports = {
} }
}; };
var rule2Result = { var rule2Result = {
atRuleType: '',
singleAtRule: 0,
important: 0, important: 0,
mediaText: '', mediaText: '',
selectors: [], selectors: [],

27
test/specs/css_composer/model/CssModels.js

@ -74,11 +74,38 @@ module.exports = {
it('toCSS wraps correctly inside media rule', () => { it('toCSS wraps correctly inside media rule', () => {
const media = '(max-width: 768px)'; const media = '(max-width: 768px)';
obj.set('atRuleType', 'media');
obj.set('mediaText', media); obj.set('mediaText', media);
obj.get('selectors').add({ name: 'test1' }); obj.get('selectors').add({ name: 'test1' });
obj.setStyle({ color: 'red' }); obj.setStyle({ color: 'red' });
expect(obj.toCSS()).toEqual(`@media ${media}{.test1{color:red;}}`); expect(obj.toCSS()).toEqual(`@media ${media}{.test1{color:red;}}`);
}); });
it('toCSS with a generic at-rule', () => {
obj.set('atRuleType', 'supports');
obj.get('selectors').add({ name: 'test1' });
obj.setStyle({ 'font-family': 'Open Sans' });
expect(obj.toCSS()).toEqual(
`@supports{.test1{font-family:Open Sans;}}`
);
});
it('toCSS with a generic single at-rule', () => {
obj.set('atRuleType', 'font-face');
obj.set('singleAtRule', 1);
obj.setStyle({ 'font-family': 'Sans' });
expect(obj.toCSS()).toEqual(`@font-face{font-family:Sans;}`);
});
it('toCSS with a generic at-rule and condition', () => {
obj.set('atRuleType', 'font-face');
obj.set('mediaText', 'some-condition');
obj.get('selectors').add({ name: 'test1' });
obj.setStyle({ 'font-family': 'Open Sans' });
expect(obj.toCSS()).toEqual(
`@font-face some-condition{.test1{font-family:Open Sans;}}`
);
});
}); });
describe('CssRules', () => { describe('CssRules', () => {

41
test/specs/parser/model/ParserCss.js

@ -133,6 +133,7 @@ module.exports = {
var str = var str =
'@media only screen and (max-width: 992px){ .test1.test2:hover{ color:red }}'; '@media only screen and (max-width: 992px){ .test1.test2:hover{ color:red }}';
var result = { var result = {
atRuleType: 'media',
selectors: ['test1', 'test2'], selectors: ['test1', 'test2'],
style: { color: 'red' }, style: { color: 'red' },
state: 'hover', state: 'hover',
@ -145,6 +146,7 @@ module.exports = {
it('Parse rule inside media query', () => { it('Parse rule inside media query', () => {
var str = '@media (max-width: 992px){ .test1.test2:hover{ color:red }}'; var str = '@media (max-width: 992px){ .test1.test2:hover{ color:red }}';
var result = { var result = {
atRuleType: 'media',
selectors: ['test1', 'test2'], selectors: ['test1', 'test2'],
style: { color: 'red' }, style: { color: 'red' },
state: 'hover', state: 'hover',
@ -167,11 +169,13 @@ module.exports = {
selectors: ['test1', 'test2'], selectors: ['test1', 'test2'],
style: { color: 'red' }, style: { color: 'red' },
state: 'hover', state: 'hover',
atRuleType: 'media',
mediaText: '(max-width: 992px)' mediaText: '(max-width: 992px)'
}, },
{ {
selectors: ['test2'], selectors: ['test2'],
style: { color: 'blue' }, style: { color: 'blue' },
atRuleType: 'media',
mediaText: '(max-width: 992px)' mediaText: '(max-width: 992px)'
} }
]; ];
@ -234,6 +238,43 @@ module.exports = {
}; };
expect(obj.parse(str)).toEqual(result); expect(obj.parse(str)).toEqual(result);
}); });
// Can't test keyframes https://github.com/NV/CSSOM/issues/95
it.skip('Parse rule with a keyframes at-rule', () => {
var str = `@keyframes {
from {opacity: 0;}
to {opacity: 1;}
}`;
var result = [
{
selectors: [],
atRuleType: 'keyframes',
selectorsAdd: 'from',
style: { opacity: '0' }
},
{
selectors: [],
atRuleType: 'keyframes',
selectorsAdd: 'to',
style: { opacity: '1' }
}
];
expect(obj.parse(str)).toEqual(result);
});
it('Parse rule with font-face at-rule', () => {
var str = `@font-face {
font-family: "Open Sans";
}`;
var result = {
selectors: [],
selectorsAdd: '',
atRuleType: 'font-face',
singleAtRule: 1,
style: { 'font-family': '"Open Sans"' }
};
expect(obj.parse(str)).toEqual(result);
});
}); });
} }
}; };

Loading…
Cancel
Save