app/jquery/jquery-growfield.js
author Lennard de Rijk <ljvderijk@gmail.com>
Tue, 25 Aug 2009 11:43:14 +0200
changeset 2795 776aae4d0499
parent 2421 0979e7af115f
permissions -rw-r--r--
Set new Melange version number to 0-5-20090825 in app.yaml.template.

/*
 * 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);