app/jquery/jquery-growfield.js
author Mario Ferraro <fadinlight@gmail.com>
Sun, 15 Nov 2009 22:12:20 +0100
changeset 3093 d1be59b6b627
parent 2421 0979e7af115f
permissions -rw-r--r--
GMaps related JS changed to use new google namespace. Google is going to change permanently in the future the way to load its services, so better stay safe. Also this commit shows uses of the new melange.js module. Fixes Issue 634.

/*
 * The MIT License
 *
 * Copyright (c) 2009 Johann Kuindji
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 *
 * @author Johann Kuindji, Dmitriy Likhten
 * http://code.google.com/p/jquery-growfield/
 */
(function($) {
if ($.support === undefined) {
    $.support = { boxModel: $.boxModel };
}
var windowLoaded = false;
$(window).one('load', function(){ windowLoaded=true; });

// we need to adapt jquery animations for textareas.
// by default, it changes display to 'block' if we're trying to
// change width or height. We have to prevent this.
// THIS WILL NOT ALTER JQUERY ORIGINAL BEHAVIORS, IT WILL HOWEVER ADD
// SOME SO THAT GROWFIELD ANIMATIONS WORK CORRECTLY.
$.fx.prototype.originalUpdate = $.fx.prototype.update;
$.fx.prototype.update = false;
$.fx.prototype.update = function () {
    if (!this.options.inline) {
        return this.originalUpdate.call(this);
    }
    if ( this.options.step ) {
        this.options.step.call( this.elem, this.now, this );
    }
    (jQuery.fx.step[this.prop] || jQuery.fx.step._default)( this );
};

$.growfield = function(dom,options){
    // Extend ptt(prototype) with our own private variables/
    // shared's functions are re-referenced and not cloned so
    // memory is kept at a minimum.
    var that = $.extend({
        dom: dom,
        o: $(dom),
        enabled: false,
        dummy: false,
        busy: false,
        initial: false,
        sizseRelated: false,
        prevH: false,
        firstH: false,
        restoreH: false,
        opt: $.extend({},$.fn.growfield.defaults,options)
    },$.growfield.ptt);

    return that;
};

//-----------------------------------------------------
// This is the base class for all $.growfield objects
// (their prototype)
//-----------------------------------------------------
$.growfield.ptt = (function(){
    //-----------------------------------------------------
    //EVENT HANDLERS for dealing with the growfield object
    //-----------------------------------------------------
    var manualKeyUp = function(e) {
        var obj = e.data;
        if (e.ctrlKey && (e.keyCode == 38 || e.keyCode == 40)){
            obj.update(
                obj.o.outerHeight() + (obj.opt.step*( e.keyCode==38? -1: 1)),
                obj.opt.animate
            );
        }
    };

    var keyUp = function(e) {
        var obj = e.data;
        if (!obj.busy){
            if ($.inArray(e.keyCode, [37,38,39,40]) === -1) {
                obj.update(obj.getDummyHeight(), obj.opt.animate);
            }
        }
        return true;
    };

    var focus = function(e) {
        var obj = e.data;
        if (!obj.busy) {
            if (obj.opt.restore) {
                obj.update(obj.dummy ? obj.getDummyHeight() : obj.restoreH, obj.opt.animate, 'growback');
            }
        }
    };

    var blur = function(e) {
        var obj = e.data;
        if (!obj.busy) {
            if (obj.opt.restore) {
                obj.update(0, obj.opt.animate, 'restore');
            }
        }
    };

    var prepareSizeRelated = function(e) {
        var obj = e.data;
        var o = obj.o;
        var opt = obj.opt;

        if (!opt.min) {
            opt.min = parseInt(o.css('min-height'), 10) || obj.firstH || parseInt(o.height(), 10) || 20;
            if (opt.min <= 0) {
                opt.min = 20; // opera fix
            }
            if (!obj.firstH) {
                obj.firstH = opt.min;
            }
        }
        if (!opt.max) {
            opt.max = parseInt(o.css('max-height'), 10) || false;
            if (opt.max <= 0) {
                opt.max = false; // opera fix
            }
        }
        if (!opt.step) {
            opt.step = parseInt(o.css('line-height'), 10) || parseInt(o.css('font-size'), 10) || 20;
        }

        var sr = {
            pt: parseInt(o.css('paddingTop'), 10)||0,
            pb: parseInt(o.css('paddingBottom'), 10)||0,
            bt: parseInt(o.css('borderTopWidth'), 10)||0,
            bb: parseInt(o.css('borderBottomWidth'), 10)||0,
            lh: parseInt(o.css('lineHeight'), 10) || false,
            fs: parseInt(o.css('fontSize'), 10) || false
        };

        obj.sizeRelated = sr;
    };

    /**
     * Create a dummy if one does not yet exist.
     */
    var createDummy = function(e) {
        var obj = e.data;
        if(!obj.dummy){
            var val = obj.o.val();
            // we need dummy to calculate scrollHeight
            // (there are some tricks that can't be applied to the textarea itself, otherwise user will see it)
            // Also, dummy must be a textarea too, and must be placed at the same position in DOM
            // in order to keep all the inherited styles
            var dummy = obj.o.clone();
            dummy.addClass('growfieldDummy');
            dummy.attr('tabindex', -9999);
            dummy.css({
                position: 'absolute',
                left: -9999,
                top: 0,
                height: '20px',
                resize: 'none'});
            // The dummy must be inserted after otherwise google chrome will
            // focus on the dummy instead of on the actual text area, focus will always
            // be lost.
            dummy.insertAfter(obj.o);
            dummy.show();

            // if there is no initial value, we have to add some text, otherwise textarea will jitter
            // at the first keydown
            if (!val) {
                dummy.val('dummy text');
            }
            obj.dummy = dummy;
            // lets set the initial height
            obj.update((!$.trim(val) || obj.opt.restore) ? 0 : obj.getDummyHeight(), false);
        }
    };

    /**
     * Remove the dummy if one exists
     */
    var removeDummy = function(e) {
        obj = e.data;
        if(obj.dummy){
            obj.dummy.remove();
            delete obj.dummy;
        }
    };

    //-----------------------------------------------------
    // END EVENT HANDLERS
    //-----------------------------------------------------

    // This will bind to $(document).ready if the height is loaded
    // or a window.load event already occurred.
    // OR it will just bind to the window.load event.
    var executeWhenReady = function(data,fn){
        if (data.o.height() !== 0 || windowLoaded) {
            $(document).ready(function(){
                fn({data:data});
            });
        }
        else {
            $(window).one('load', data, fn);
        }
    };

    //-----------------------------------------------------
    // Public methods.
    //-----------------------------------------------------
    var that = {
        // Toggle the functionality.
        // enable or true will enable growfield
        // disable or false will disable growfield
        toggle: function(mode) {
            if ((mode=='disable' || mode===false)&&this.enabled) {
                this.unbind();
            }
            else if ((mode=='enable' || mode===true)&&!this.enabled) {
                this.bind();
            }
            return this;
        },

        // Bind all growfield events to the object.
        bind: function(){
            executeWhenReady(this,prepareSizeRelated);
            var opt = this.opt;
            var o = this.o;

            // auto mode, textarea grows as you type
            if (opt.auto) {

                o.bind('keyup.growfield', this, keyUp);
                this.initial = {
                    overflow: this.o.css('overflow'),
                    cssResize: this.o.css('resize')
                };
                // We want to ensure that safari and google chrome do not allow
                // the user to drag-to-resize the field. This should only be enabled
                // if auto mode is disabled.
                if ($.browser.safari) {
                    o.css('resize', 'none');
                }
                o.css('overflow','hidden');

                o.bind('focus.growfield', this, createDummy);
                // all styles must be loaded before prepare elements
                // we need to ensure the dummy exists at least for a short
                // time so that we can calculate the initial state...
                executeWhenReady(this, createDummy);
                executeWhenReady(this, removeDummy);
            }
            // manual mode, textarea grows as you type ctrl + up|down
            else {
                o.bind('keydown.growfield', this, manualKeyUp);
                o.css('overflow-y', 'auto');
                executeWhenReady(this,function(e){
                    e.data.update(e.data.o.height());
                });
            }
            o.bind('focus.growfield', this, focus);
            o.bind('blur.growfield', this, blur);
            o.bind('blur.growfield', this, removeDummy);

            // Custom events provided in options
            if (opt.onHeightChange) {
                o.bind('onHeightChange.growfield', opt.onHeightChange);
            }
            if (opt.onRestore) {
                o.bind('onRestore.growfield', opt.onRestore);
            }
            if (opt.onGrowBack) {
                o.bind('onGrowBack.growfield', opt.onGrowBack);
            }

            this.enabled = true;

            return this;
        },

        // Unbind all growfield events from the object (including custom events)
        unbind: function() {
            removeDummy({data:this});
            this.o.unbind('.growfield');
            this.o.css('overflow', this.initial.overflow);
            if ($.browser.safari) {
                this.o.css('resize', this.initial.cssResize);
            }
            this.enabled = false;

            return this;
        },

        // Trigger custom events according to updateMode
        triggerEvents: function(updateMode) {
            var o = this.o;
            o.trigger('onHeightChange.growfield');
            if (updateMode == 'restore') {
                o.trigger('onRestore.growfield');
            }
            if (updateMode == 'growback') {
                o.trigger('onGrowBack.growfield');
            }
        },

        update: function(h, animate, updateMode) {
            var sr = this.sizeRelated;
            var val = this.o.val();
            var opt = this.opt;
            var dom = this.dom;
            var o = this.o;
            var th = this;
            var prev = this.prevH;
            var noHidden = !opt.auto;
            var noFocus = opt.auto;

            h = this.convertHeight(Math.round(h), 'inner');
            // get the right height according to min and max value
            h = opt.min > h ? opt.min :
                  opt.max && h > opt.max ? opt.max :
                  opt.auto && !val ? opt.min : h;

            if (opt.max && opt.auto) {
                if (prev != opt.max && h == opt.max) { // now we reached maximum height
                    o.css('overflow-y', 'scroll');
                    if (!opt.animate) {
                        o.focus(); // browsers do loose cursor after changing overflow :(
                    }
                    noHidden = true;
                    noFocus = false;
                }
                if (prev == opt.max && h < opt.max) {
                    o.css('overflow-y', 'hidden');
                    if (!opt.animate) {
                        o.focus();
                    }
                    noFocus = false;
                }
            }

            if (h == prev) {
                return true;
            }
            // in case of restore in manual mode we have to store
            // previous height (we can't get it from dummy)
            if (!opt.auto && updateMode == 'restore') {
                this.restoreH = this.convertHeight(this.prevH, 'outer');
            }
            this.prevH = h;

            if (animate) {
                th.busy = true;
                o.animate({height: h}, {
                    duration: opt.animate,
                    easing: ($.easing ? opt.easing : null),
                    overflow: null,
                    inline: true, // this option isn't jquery's. I added it by myself, see above
                    complete: function(){
                        // safari/chrome fix
                        // somehow textarea turns to overflow:scroll after animation
                        // i counldn't find it in jquery fx :(, so it looks like some bug
                        if (!noHidden) {
                            o.css('overflow', 'hidden');
                        }
                        // but if we still need to change overflow (due to opt.max option)
                        // we have to invoke focus() event, otherwise browser will loose cursor
                        if (!noFocus && updateMode != 'restore') {
                            o.focus();
                        }
                        if (updateMode == 'growback') {
                            dom.scrollTop = dom.scrollHeight;
                        }
                        th.busy = false;
                        th.triggerEvents(updateMode);
                    },
                    queue: false
                });
            } else {
                dom.style.height = h+'px';
                this.triggerEvents(updateMode);
            }
        },

        getDummyHeight: function() {
            var val = this.o.val();
            var h = 0;
            var sr = this.sizeRelated;
            var add = "\n111\n111";

            // Safari has some defect with double new line symbol at the end
            // It inserts additional new line even if you have only one
            // But that't not the point :)
            // Another question is how much pixels to keep at the bottom of textarea.
            // We'll kill many rabbits at the same time by adding two new lines at the end
            // (but if we have font-size and line-height defined, we'll add two line-heights)
            if ($.browser.safari) {
                val = val.substring(0, val.length-1); // safari has an additional new line ;(
            }

            if (!sr.lh || !sr.fs) {
                val += add;
            }

            this.dummy.val(val);

            // IE requires to change height value in order to recalculate scrollHeight.
            // otherwise it stops recalculating scrollHeight after some magical number of pixels
            if ($.browser.msie) {
                this.dummy[0].style.height = this.dummy[0].scrollHeight+'px';
            }

            h = this.dummy[0].scrollHeight;

            // if line-height is greater than font-size we'll add line-height + font-size
            // otherwise font-size * 2
            // there is no special logic in this behavior, it's been developed from visual testing
            if (sr.lh && sr.fs) {
                h += sr.lh > sr.fs ? sr.lh+sr.fs :  sr.fs * 2;
            }

            // now we have to minimize dummy back, or we'll get wrong scrollHeight next time
            //if ($.browser.msie) {
            //    this.dummy[0].style.height = '20px'; // random number
            //}

            return h;
        },

        convertHeight: function(h, to) {
            var sr = this.sizeRelated, mod = (to=='inner' ? -1 : 1), bm = $.support.boxModel;
            // what we get here in 'h' is scrollHeight value.
            // so we need to subtract paddings not because of boxModel,
            // but only if browser includes them to the scroll height (which is not defined by box model)
            return h
                + (bm ? sr.bt : 0) * mod
                + (bm ? sr.bb : 0) * mod
                + (bm ? sr.pt : 0) * mod
                + (bm ? sr.pb : 0) * mod;
        }

    };

    return that;
})();

/**
 * The growfield function. This will make a textarea a growing text area.
 *
 * @param {Object} options - See API for details on possible paramaters.
 */
$.fn.growfield = function(options) {
    // enable/disable is same thing as true/false
    switch(options){
        case 'enable':
            options = true;
            break;
        case 'disable':
            options = false;
            break;
    }

    // we need to know what was passed as the options
    var tp = typeof options;

    // These variables are used to reduce string comparisons
    // happening over and over.
    var conditions = {
        bool: tp == 'boolean',
        string: tp == 'string',
        object: tp == 'object',
        restart: options == 'restart',
        destroy: options == 'destroy'
    };

    // If the type of the options is a string
    // and is not one of the pre-defined ones, then
    // options is a preset.
    if(conditions.string && !conditions.destroy && !conditions.restart){
        options = $.fn.growfield.presets[options];
        // change to new conditions
        conditions.string = false;
        conditions.object = true;
    }

    // completely remove growfield from the dom elements
    if (conditions.destroy) {
        this.each(function() {
            var self = $(this);
            var gf = self.data('growfield');
            if (gf !== undefined) {
                gf.unbind();
                self.removeData('growfield');
            }
        });
    }
    // Apply growfield
    else {
        var textareaRegex = /textarea/i;
        this.each(function() {
            // only deal with textareas which are not dummy fields.
            if (textareaRegex.test(this.tagName) && !$(this).hasClass('growfieldDummy')) {
                var o = $(this);
                var gf = o.data('growfield');
                // Create the new options
                if (gf === undefined) {
                    gf = $.growfield(this,options);
                    o.data('growfield', gf);

                    // Bind only if the options is not a boolean
                    // or is not "false". Because options = a false boolean
                    // indicates intial bind should not happen.
                    if(!conditions.bool || options){
                        gf.bind();
                    }
                }
                // Otherwise apply actions based on the options provided
                else {
                    // If new options provided, set them
                    if(conditions.object && options) {
                        $.extend(gf.opt,options);
                    }
                    // If toggling enable/disable then do it
                    else if (conditions.bool) {
                        gf.toggle(options);
                    }
                    // If restarting, restart
                    else if (conditions.restart) {
                        gf.unbind();
                        gf.bind();
                    }
                }
            }
        });
    }

    return this;
};

/**
 * These are the default options to use, unless specified when invoking growfield.
 */
$.fn.growfield.defaults ={
    // Should the growfield automatically expand?
    auto: true,
    // The animation speed for expanding (false = off)
    animate: 100,
    // The easiny function to use, if the jquery.easing plugin is not present during
    // execution, this will always be treated as null regardless of the set value
    easing: null,
    // The minimum height (defaults to CSS min-height, or the current height of the element)
    min: false,
    // The maximum height (defaults to CSS max-height, or unlimited)
    max: false,
    // Should the element restore to it's original size after focus is lost?
    restore: false,
    // How many pixels to expand when the user is about to have to scroll. Defaults to 1 line.
    step: false
};

/**
 * These are presets. The presets are indexed by name containing different preset
 * option objects. When growfield is invoked with the preset's name, that options object
 * is loaded without having to be specified each time.
 */
$.fn.growfield.presets = {};

})(jQuery);