Thursday, November 1, 2007

E4CSS: Turn your stylesheets into ESON Objects (1)

We will see in this entry how we can turn our stylesheets into ESON objects with Javascript.
E4CSS will allow us to manipulate the data thru Javascript and Flash, bringing us even more animation/interactivity capacities!

Final API example


Before we deal with cross-browsers fixes and the ESONification, we will see the end user program:
//get the ESON object by specifying the stylesheet

var css = new E4CSS('layout.css'); //or 0,1

alert(css['#errors .warnings p']["fontSize"]); //give you back 12px

//modify the stylesheet
css.insertRule({"#debugConsole":{'background-color':'blue','font-size':'15px'}});

//delete a rule
css.deleteRule("#debugConsole");

As you can see this is really straightforward!
We will just see how to create the ESON in this entry as we will need to deal with cross-browsers fix!(although, we won't see all of them)
In order to understand what is the recommandation, we will see the standard and browser implementation way of doing it.

Standard and browser implementation


First we will see how standards define the API to access stylesheets.
Basically Firefox tries to stick to these standards so the code below will work in this browser:
//you cannot directly specify the stylesheet name, 
//you need to call it by its position in the xhtml file...
var sheets=document.styleSheets;
//get the style sheet object for the first stylesheet
var sheet=sheets[0];

// get the style sheet contents object 
var sheetRules=sheet.cssRules; 

//you cannot directly specify the selector
//you need to call it by its position in the stylesheet!

alert(sheetRules[12].selectorText); // is the selector you work on
alert(sheetRules[12].style['font-size']); // alert 12px;

//insert a new rule in the style sheet
sheet.insertRule("#debugConsole : { background-color:blue; font-size:15px }",sheetRules.length);

//delete a rule by its position in the stylesheet...
sheet.deleteRule(12);
As you can see this is far to be the easiest way of dealing with stylesheet contents as most of the time you need to refer to element positions : the style sheet position, the selector position...
I don't know for you but I can remember the name of the selectors but I hardly ever know their position in the stylesheet! Even more, I can remember the name of the stylesheet but can hardly remember the order in which I have inserted them in the xhtml document (well, it depends on the number of stylesheets you use)

This was the standard way but as usual, IE has its own naming conventions:
-cssRules becomes rules
-insertRule becomes addRule with different parameters
-deleteRule becomes removeRule

They are many other differences regardings the methods IE offers to deal with the style sheets.We will not see them all though as not all of them we'll be helpful for our main purpose : Creating an Object from the style sheet to manipulate it thru Javascript or Actionscript.

Getting the hands dirty


Even if we haven't seen all the differences you can see that not everything is gonna be easy.
First let's see how to create a function that will allow us to retrieve a style sheet thru its position number or its name:
var getCssRules = function(val) {
     var sheets=(document.styleSheets) ? document.styleSheets : undefined;
     if(typeof val=='number') {
         if(val > sheets.length) return;
         return sheets[val]['cssRules' || 'rules'] || undefined;
     }
     var regex=new RegExp(val);
     for(i in sheets) {
        if(regex.test(sheets[i].href)) {
            return sheets[i]['cssRules' || 'rules'] || undefined;
        }
        return undefined;
    }
}
The code isn't too hard as we are just dealing with cross-browsers fixes:
First we check to see if we can get the style sheets objects, and store it in a variable.
if the parameter is a number, we check that the number isn't greater than the number of style sheets.
If it's ok,we try the IE or standard methods and as a last resort we return undefined.
If this is not a number, we are dealing with a string.
So we just loop thru each style sheets objects and test the href property to see if we find it and if not return undefined.
At that point, we just have an improved cross-browser document.styleSheets function that allows us to specify the style sheet number or file name.
Now that we have the object we will need to loop thru it in order to create our own ESON version of the object.
We will use the document.styleSheets.cssRules advantages :
- no comments,
- a space between the attribute and its value inserted for us
But we also have huge disadvantages:
- IE put in Uppercase all the xhtml tags in the selector !
- IE put all the attributes in uppercase!
- Firefox changes all the colors in the rgb notations while IE keeps what comes in
- Firebox, IE and Safari don't send back the same attributes when an attribute can be divided in several sub elements ie border:'1px solid red' has borderTopWidth,borderTopStyle,borderTopColor, borderLeftWidth,borderLeftStyle...) IE sends back two attributes for the above example, Firefox sends back 4 attributes and Safari is the winner with 12!
- IE keeps multiple selectors separted by ',' into one selector where others separate them so h1,h2 {} will be h1 {} and h2 {} let's see the objects we'd like to get back:
{
 '#navigation' :{
        'backgroundColor':'#000000',
        'height':'20px',
        'width':'600px'
 },
 '#header a:hover' : {
        'color':'#FF0000',
        'fontSize':'12px'
 }
}
Let's see the code:
var parse = function(cssFileName) {

    //we get the object in a cross browser way
    var rules=getCssRules(cssFileName);
    if(!rules) return;

    //we instantiate the object that will keep our properties
    var objs=new Object();//or just function () {}

    //we loop thru each selectors in the stylesheet
    for(var i=0;i < rules.length;i++ ) {

        //we get the name of the selector
 var selector=rules[i].selectorText;
 if(!selector) continue;

        // we split the attributes by their ';'
 var csss=rules[i].style.cssText.split(';');

        //we create an object that will store attributes:value pairs
 var props={};

        //we loop thru the array we created from the split
 for(var j=0;j< csss.length;j++ ) {

            //we split each pairs by their ':'
     var s=csss[j].split(/:\s/);
     if(s[0].length==1 || !s[0] || !s[1]) continue;

            //we change everything in camelCase and lowercase
     var tmp=camelize(s[0].toLowerCase());
            //we trim some unwelcome spaces
     props[trim(tmp)]=s[1];
       }
       //we split selectors
       var selectors=selector.split(',') || [selector];
       //loop thru them and add the above attributes
       for(k=0;k < selectors.length;k++ ) {
   if(objs[selectors[k]]) {
      for(var attr in props)
  objs[selectors[k].toLowerCase()][attr]=props[attr];
   }
   else objs[selectors[k].toLowerCase()]=props; 
       }
    }
    return objs;
}
They are some works that we still need to do to have the same objects across browsers.

That's why standards are great:
They don't make you hack your hack !

Here are the helpers camelize and trim:
function camelize(val) {
   return val.replace(/-(.)/g, function(m, l){ return l.toUpperCase()});
}
function trim(val) {
   var val= val.replace(/^\s+/,'');
   return val.replace(/\s+$/,'');
}
As you see, we have been created all the functions in the global space, that is the window object.
In order to avoid conflicts with other libraries that we may use, we should put this in a namespace.
We will create a basic class that will allow us to create a namespace but also keep track of the style sheets loaded in the object
Here we go:
function E4CSS(val) {
     return (this instanceof E4CSS) ? this.parse(val) : new E4CSS(val);
}
This is the constructor of the class.
if the function is called without the new keyword binding the this keyword to the E4CSS object we just instantiate the object for our user!
Therefore, we could call the constructor:
var css= new E4CSS('layout.css');
//or
var css= E4CSS('layout.css');
Now we are just going to put the above two functions we have seen into the prototype of the class.
E4CSS.prototype={

     styleSheet : 0,
     sheets     : (document.styleSheets) ? document.styleSheets : undefined,

     getCssRules : function(val) {

       if(val=='undefined') return this;
       if(this.sheets=='undefined')  return undefined;

       if(typeof val=='number') {
          this.styleSheet=val;
          if(val > this.sheets.length) return;
          this.sheets[val]['cssRules' || 'rules'];
      }
     var regex=new RegExp(val);
     for(i in this.sheets) {
        if(regex.test(this.sheets[i].href)) {
          this.styleSheet=i;
          return this.sheets[i]['cssRules'||'rules'] || this;
       }
   }
 },

 parse : function(cssFileName) {

    var rules=this.getCssRules(cssFileName);
    if(!rules) return this;

    var objs=new Object();

    for(var i=0;i < rules.length;i++) {

        var selector=rules[i].selectorText;
        if(!selector) continue;

        var csss=rules[i].style.cssText.split(';');
        var props={};
        for(var j=0;j < csss.length;j++) {
    var s=csss[j].split(/:\s/);
    if(s[0].length==1 || !s[0] || !s[1]) continue;
    var tmp=camelize(s[0].toLowerCase());
    props[trim(tmp)]=s[1];
        }
        var selectors=selector.split(',') || [selector];
        for(k=0;k < selectors.length;k++) {
    if(objs[selectors[k]]) {
        for(var attr in props)
   objs[selectors[k].toLowerCase()][attr]=props[attr];
    }
    else objs[selectors[k].toLowerCase()]=props;           }
   }
   for(var prop in this) objs[prop]=this[prop];
   return objs;
      }
}
We just take the functions and put them in the namespace of the E4CSS class.
we added a default value for the style sheet we will deal with.
Before returning the new object, we extend its possibilities with the one of the E4CSS.
Therefore, you will be able to call the parse function on the new object!
For now this is not very helpful but this will come in handy when we will see how to add insertRule and deleteRule on the object!
So for now, you just have a read object that allows you to do the following:
var css=new E4CSS('layout.css');
alert(css["#navigation"]["backgroundColor"]);
css.parse(3);
alert(css.toSource()); // firefox function to see the object structure.

Conclusion


We have seen how we could change a css style sheet into ESON allowing us for now to read the property in both actionscript or javascript!
We choose to camelize the css attributes as this is the way both javascript and actionscript access them.
We didn't do anything for the color and for the multipart attributes for now.
Safari sends back 12 properties when Firefox sends back 4 and IE sends back 2!!
If we think of the use we could have from within a javascript or flash application, the more the better.
We will see how to insert new rules or delete rules in a cross-browsers way from within our E4CSS objects in the next entry.

2 comments:

Philippe said...

Just discovered your blog thanks to dzone - great post! Now what about Safari, Opera?

shiriru said...

Hi, philippe!
Thank your for your comment!
The above script should work in IE6+,Firefox1.5+,Safari win/mac(latest version)
I didn't try under Opera though.
I am going to talk about E4CSS in an other entry to animate elements thru their css selectors directly and write an other entry to deal with adding/deleting selectors/properties in the stylesheet.(Safari needs to hack a little though)

The huge advantage of dealing with the stylesheet directly is that you don't have to wait for the document to be loaded and I guess I forgot to specify that!

I will update the code in the next entry and put a list of browsers that should work.