A jQuery Plug-in to monitor Html Element CSS Changes

Posted by Rick Strahl on West-Wind See other posts from West-Wind or by Rick Strahl
Published on Tue, 22 Feb 2011 09:17:44 GMT Indexed on 2011/02/23 7:25 UTC
Read the original article Hit count: 484

Filed under:
|
|

Here's a scenario I've run into on a few occasions: I need to be able to monitor certain CSS properties on an HTML element and know when that CSS element changes. The need for this arose out of wanting to build generic components that could 'attach' themselves to other objects and monitor changes on the ‘parent’ object so the dependent object can adjust itself accordingly.

What I wanted to create is a jQuery plug-in that allows me to specify a list of CSS properties to monitor and have a function fire in response to any change to any of those CSS properties. The result are the .watch() and .unwatch() jQuery plug-ins. Here’s a simple example page of this plug-in that demonstrates tracking changes to an element being moved with draggable and closable behavior:

http://www.west-wind.com/WestWindWebToolkit/samples/Ajax/jQueryPluginSamples/WatcherPlugin.htm

Try it with different browsers – IE and FireFox use the DOM event handlers and Chrome, Safari and Opera use setInterval handlers to manage this behavior. It should work in all of them but all but IE and FireFox will show a bit of lag between the changes in the main element and the shadow.

The relevant HTML for this example is this fragment of a main <div> (#notebox) and an element that is to mimic a shadow (#shadow).

<div class="containercontent">
   <div id="notebox" style="width: 200px; height: 150px;position: absolute; 
z-index: 20; padding: 20px; background-color: lightsteelblue;"> Go ahead drag me around and close me! </div> <div id="shadow" style="background-color: Gray; z-index: 19;position:absolute;display: none;"> </div> </div>

The watcher plug in is then applied to the main <div> and shadow in sync with the following plug-in code:

<script type="text/javascript">
    $(document).ready(function () {
        var counter = 0;
        $("#notebox").watch("top,left,height,width,display,opacity", function (data, i) {
            var el = $(this);
            var sh = $("#shadow");

            var propChanged = data.props[i];
            var valChanged = data.vals[i];

            counter++;
            showStatus("Prop: " + propChanged + "  value: " + valChanged + "  " + counter);

            var pos = el.position();
            var w = el.outerWidth();
            var h = el.outerHeight();

            sh.css({
                width: w,
                height: h,
                left: pos.left + 5,
                top: pos.top + 5,
                display: el.css("display"),
                opacity: el.css("opacity")
            });
        })
        .draggable()
        .closable()
        .css("left", 10);
    });
</script>

When you run this page as you drag the #notebox element the #shadow element will maintain and stay pinned underneath the #notebox element effectively keeping the shadow attached to the main element. Likewise, if you hide or fadeOut() the #notebox element the shadow will also go away – show the #notebox element and the shadow also re-appears because we are assigning the display property from the parent on the shadow.

Note we’re attaching the .watch() plug-in to the #notebox element and have it fire whenever top,left,height,width,opacity or display CSS properties are changed. The passed data element contains a props[] and vals[] array that holds the properties monitored and their current values. An index passed as the second parm tells you which property has changed and what its current value is (propChanged/valChanged in the code above). The rest of the watcher handler code then deals with figuring out the main element’s position and recalculating and setting the shadow’s position using the jQuery .css() function.

Note that this is just an example to demonstrate the watch() behavior here – this is not the best way to create a shadow. If you’re interested in a more efficient and cleaner way to handle shadows with a plug-in check out the .shadow() plug-in in ww.jquery.js (code search for fn.shadow) which uses native CSS features when available but falls back to a tracked shadow element on browsers that don’t support it, which is how this watch() plug-in came about in the first place :-)

How does it work?

The plug-in works by letting the user specify a list of properties to monitor as a comma delimited string and a handler function:

el.watch("top,left,height,width,display,opacity", function (data, i) {}, 100, id)

You can also specify an interval (if no DOM event monitoring isn’t available in the browser) and an ID that identifies the event handler uniquely.

The watch plug-in works by hooking up to DOMAttrModified in FireFox, to onPropertyChanged in Internet Explorer, or by using a timer with setInterval to handle the detection of changes for other browsers. Unfortunately WebKit doesn’t support DOMAttrModified consistently at the moment so Safari and Chrome currently have to use the slower setInterval mechanism. In response to a changed property (or a setInterval timer hit) a JavaScript handler is fired which then runs through all the properties monitored and determines if and which one has changed. The DOM events fire on all property/style changes so the intermediate plug-in handler filters only those hits we’re interested in. If one of our monitored properties has changed the specified event handler function is called along with a data object and an index that identifies the property that’s changed in the data.props/data.vals arrays.

The jQuery plugin to implement this functionality looks like this:

(function($){
$.fn.watch = function (props, func, interval, id) {
    /// <summary>
    /// Allows you to monitor changes in a specific
    /// CSS property of an element by polling the value.
    /// when the value changes a function is called.
    /// The function called is called in the context
    /// of the selected element (ie. this)
    /// </summary>    
    /// <param name="prop" type="String">CSS Properties to watch sep. by commas</param>    
    /// <param name="func" type="Function">
    /// Function called when the value has changed.
    /// </param>    
    /// <param name="interval" type="Number">
    /// Optional interval for browsers that don't support DOMAttrModified or propertychange events.
    /// Determines the interval used for setInterval calls.
    /// </param>
    /// <param name="id" type="String">A unique ID that identifies this watch instance on this element</param>  
    /// <returns type="jQuery" /> 
    if (!interval)
        interval = 100;
    if (!id)
        id = "_watcher";

    return this.each(function () {
        var _t = this;
        var el$ = $(this);
        var fnc = function () { __watcher.call(_t, id) };

        var data = { id: id,
            props: props.split(","),
            vals: [props.split(",").length],
            func: func,
            fnc: fnc,
            origProps: props,
            interval: interval,
            intervalId: null
        };
        // store initial props and values
        $.each(data.props, function (i) { data.vals[i] = el$.css(data.props[i]); });

        el$.data(id, data);

        hookChange(el$, id, data);
    });

    function hookChange(el$, id, data) {
        el$.each(function () {
            var el = $(this);
            if (typeof (el.get(0).onpropertychange) == "object")
                el.bind("propertychange." + id, data.fnc);
            else if ($.browser.mozilla)
                el.bind("DOMAttrModified." + id, data.fnc);
            else
                data.intervalId = setInterval(data.fnc, interval);
        });
    }
    function __watcher(id) {
        var el$ = $(this);
        var w = el$.data(id);
        if (!w) return;
        var _t = this;

        if (!w.func)
            return;

        // must unbind or else unwanted recursion may occur
        el$.unwatch(id);

        var changed = false;
        var i = 0;
        for (i; i < w.props.length; i++) {
            var newVal = el$.css(w.props[i]);
            if (w.vals[i] != newVal) {
                w.vals[i] = newVal;
                changed = true;
                break;
            }
        }
        if (changed)
            w.func.call(_t, w, i);

        // rebind event
        hookChange(el$, id, w);
    }
}
$.fn.unwatch = function (id) {
    this.each(function () {
        var el = $(this);
        var data = el.data(id);
        try {
            if (typeof (this.onpropertychange) == "object")
                el.unbind("propertychange." + id, data.fnc);
            else if ($.browser.mozilla)
                el.unbind("DOMAttrModified." + id, data.fnc);
            else
                clearInterval(data.intervalId);
        }
        // ignore if element was already unbound
        catch (e) { }
    });
    return this;
}
})(jQuery);

Note that there’s a corresponding .unwatch() plug-in that can be used to stop monitoring properties. The ID parameter is optional both on watch() and unwatch() – a standard name is used if you don’t specify one, but it’s a good idea to use unique names for each element watched to avoid overlap in event ids especially if you’re monitoring many elements.

The syntax is:

$.fn.watch = function(props, func, interval, id)

props
A comma delimited list of CSS style properties that are to be watched for changes. If any of the specified properties changes the function specified in the second parameter is fired.

func
The function fired in response to a changed styles. Receives this as the element changed and an object parameter that represents the watched properties and their respective values. The first parameter is passed in this structure:

{ id: watcherId, props: [], vals: [], func: thisFunc, fnc: internalHandler, origProps: strPropertyListOnWatcher };

A second parameter is the index of the changed property so data.props[i] or data.vals[i] gets the property and changed value.

interval
The interval for setInterval() for those browsers that don't support property watching in the DOM. In milliseconds.

id
An optional id that identifies this watcher. Required only if multiple watchers might be hooked up to the same element. The default is _watcher if not specified.

It’s been a Journey

I started building this plug-in about two years ago and had to make many modifications to it in response to changes in jQuery and also in browser behaviors. I think the latest round of changes made should make this plug-in fairly future proof going forward (although I hope there will be better cross-browser change event notifications in the future).

One of the big problems I ran into had to do with recursive change notifications – it looks like starting with jQuery 1.44 and later, jQuery internally modifies element properties on some calls to some .css()  property retrievals and things like outerHeight/Width(). In IE this would cause nasty lock up issues at times. In response to this I changed the code to unbind the events when the handler function is called and then rebind when it exits. This also makes user code less prone to stack overflow recursion as you can actually change properties on the base element. It also means though that if you change one of the monitors properties in the handler the watch() handler won’t fire in response – you need to resort to a setTimeout() call instead to force the code to run outside of the handler:

$("#notebox")
el.watch("top,left,height,width,display,opacity", function (data, i) {
    var el = $(this);

// this makes el changes work setTimeout(function () { el.css("top", 10) },10); })

Since I’ve built this component I’ve had a lot of good uses for it. The .shadow() fallback functionality is one of them.

Resources

The watch() plug-in is part of ww.jquery.js and the West Wind West Wind Web Toolkit. You’re free to use this code here or the code from the toolkit.

    © Rick Strahl, West Wind Technologies, 2005-2011
    Posted in ASP.NET  JavaScript  jQuery  
    kick it on DotNetKicks.com

    © West-Wind or respective owner

    Related posts about ASP.NET

    Related posts about JavaScript