1 /**
 * @author Garrett Smith, � 2008 
 * @version 1
 * @fileoverview:
 * contains: APE.drag.Draggable, DragHandlers, APE.drag.DropTarget
 *
 * @requires APE.EventPublisher, APE.dom
 * 
 * classNames:
 * <ul>
 *   <li>activeDragClassName</li>
 *   <li>focusClassName</li>
 *   <li>selectedClassName</li>
 *   <li>dragOverClassName (for dropTarget)</li>
 *   <li>focusClassName (focused drag object)</li>
 * </ul>
 *
 * APE.drag.Draggable Features:
 * <ul>
 *   <li>dragCopy</li>
 *   <li>dragMultiple</li>
 *   <li>setHandle(handle, useTree)</li>
 * </ul>
 *
 * @example Create a Draggable:
 *<pre>
 * var Draggable = APE.drag.Draggable,
 *     el = document.getElementById(<var>"box"</var>),
 *     box = Draggable.getByNode( el );
 * box.keepInContainer = true;
 * box.activeDragClassName = "boxDragging";
 * box.focusClassName = "boxFocused";
 * 
 * var bigBx = box.addDropTarget( document.getElementById("biggerBox") );
 * bigBx.dragOverClassName = "boxDragOver";
 * </pre>
 */

/** @name APE.drag.Draggable
 * @namespace */
APE.namespace("APE.drag");

/** 
 * @private
 * @description Do not call the constructor directly.
 * @param {HTMLElement} el the element to drag.
 * @param {uint} [constraint] (0 | 1 | 2) default is 0.
 * @see APE.drag.Draggable.constraints
 */
APE.drag.Draggable = function(el, constraint) {
    this.id = el.id;
    this.el = this.origEl = el;
    this.style = el.style;
    this.isRel = APE.dom.getStyle(el, "position").toLowerCase() == "relative";

    // default 'container' is the containing block.
    this.container = (this.isRel ? el.parentNode : APE.dom.getContainingBlock(el));
    this.dropTargets = [];
    this.handle = el;
    this.constraint = constraint||0;
    this.init();
};

/** 
 * @memberOf APE.drag.Draggable
 * @function
 * @return {APE.drag.Draggable} 
 * @param {HTMLElement} el 
 * @param {uint} [constraint] (0 | 1 | 2) default is 0.
 * @static
 * @description Use Draggable.getByNode, <em>not</em> new. */
APE.drag.Draggable.getByNode = APE.getByNode;    

/** @name APE.drag.instanceDestructor
 * @private
 * @param {HTMLElement} el the element to drag
 * @param {uint} constraint   
 * @see APE.drag.Draggable.constraints
 */
APE.drag.Draggable.instanceDestructor = function() {
    var x, p, dO, DragHandlers = APE.drag.DragHandlers;
    for(x in this.instances) {
        dO = this.instances[x];
        for(p in dO)
            if(dO.hasOwnProperty(p)) 
                delete dO[p];
        delete this.instances[x];
    }
    if(dO) {
        dO.constructor.draggableList = {};
        DragHandlers.focusedDO = DragHandlers.dO = null;
    }
};

/** 
 * @private
 *  Callback handler for a draggable's intrinsic focus event.
 */
APE.drag.Draggable.focused = function(e) {

    // TODO: separate concern.
    // IE will fire focus events when, in retireClone, when this.el = this.origEl.
    // This can be demonstrated by holding the mouse down on a dragObj for 2 sec, then releasing the mouse.
    if(timeStamp - arguments.callee.timeStamp < 5) return; // recurrant.
    arguments.callee.timeStamp = timeStamp; // record.
    e = e||event;

    var timeStamp = new Date-0;

    if(typeof e.stopPropagation == "function") 
        e.stopPropagation();
    else 
        e.cancelBubble = true;
    this.setFocus(true, e);
};

/** 
 * @private
 *  Callback handler for a draggable's intrinsic focus event.
 */
APE.drag.Draggable.blurred = function(e) {
    this.setFocus(false, e);
};

/** 
 * @memberOf APE.drag.Draggable
 */
APE.drag.Draggable.constraints = { NONE : 0, HORZ : 1, VERT : 2 };

APE.drag.Draggable.prototype = {
    
    /** @type {boolean} 
     * @private 
     * @description internal flag
     */
    hasFocus : false,

    /** @type {boolean} 
     * @description set to true to make a temporary "ghost" copy dragged.
     */
    dragCopy : false,

    /** @type {boolean} 
     * @description set to true to allow this to be dragged as a group.
     */
    dragMultiple : false,
 
    isSelected : false,

/**
 *  A subset of dropTargets that have ondragover or ondragout.
 *  created onmousedown, to help boost performance by reducing count for ondragover 
 *     
 **/    

    _dragOverTargets : false,

   /** @event
    * @description Has been grabbed.
    */
   onfocus : undefined,
   /** @event
    * @description Has been blurred.
    */
   onblur : undefined,

   /** @event
    * @description Is about to move.
    */
   onbeforedrag : undefined,

   /** @event
    * @description Has been grabbed.
    */
   onbeforedragstart : undefined,
   /** @event
    *  @param {Event} e dom event.
    * Mouse has moved.
    */
   ondragstart : undefined,
     
   /** @event
    *  @param {Event} e dom event.
    *  @description Being dragged
    */
   ondrag : undefined,
 
   /** @event
    *  @description Dragging stopped before it escaped its container. 
    */
   ondragstop : undefined,
 
   /** @event
    *  @description Dragging completed (as a result of mouseup). 
    */
   ondragend : undefined,
 

    /**@type {Number} 
     * @description current x position*/
    x : 0,
    /**@type {Number} 
     * @description current y position*/
    y : 0,
    /**@type {Number} 
     * @description where drag started from */
    origX : 0,
    /**@type {Number} 
     * @description where drag started from */
    origY : 0,
    /**@type {Number} 
     * @description where draggable was grabbed from */
    grabX : 0,
    /**@type {Number} 
     * @description where draggable was grabbed from */
    grabY : 0,

    /** @type {Number} 
     * @description Where it will move to next. onbeforedrag */
    newX : 0, 
    /** @type {Number} 
     * @description Where it will move to next. onbeforedrag */
    newY : 0,

    /** @type {Number} 
     * @description default: APE.drag.Draggable.constraints.NONE */
    constraint : APE.drag.Draggable.constraints.NONE,

    /** @type {boolean} 
     * @description drag object can be dragged outside of its container */
    keepInContainer : false,
    /** @type {boolean} 
     * @description drag object can be disabled by setting to this to false */
    isDragEnabled : true,
    
    /** @type {String} 
     * @description className to add when selected. */
    selectedClassName : "",
    /** @type {String} 
     * @description className to add before being dragged. */
    activeDragClassName : "",
    /** @type {String} 
     * @description className to add when focused. */
    focusClassName : "",

    init : function(){
        var EventPublisher = APE.EventPublisher,
            drag = APE.drag,
            Draggable = drag.Draggable,
            el = this.el;
        el.style.zIndex = APE.dom.getStyle(el, "zIndex") || Draggable.highestZIndex++;
        
        this._setIeTopLeft();

        EventPublisher.add(el, "onfocus", Draggable.focused, this);
        EventPublisher.add(el, "onblur", Draggable.blurred, this);

        // For IE, if the attribute is not present, 0 will be returned.
        // For Moz, Webkit, Op, if attribute is not present, null will be returned,
        // but the default value for the DOM property will be -1 (truthy), so use getAttribute. 
        if(!el.getAttribute('tabIndex')) el.tabIndex = 0; // Allow default kbd navigation.

        /** Will be dragged */
        this.onbeforeexitcontainer = function() { return !this.keepInContainer; };

        drag.DragHandlers.init();
    },

    useHandleTree : true,

    hasHandleSet : false,

    /** Sets a handle on a draggable 
     * @param {HTMLElement} el the element to use as a handle.
     * By default, the handle is the draggable.
     * @param {boolean} [setHandleTree] if true, the draggable can use anything in the 
     * handle's subtree for dragging.
     */
    setHandle : function(el, setHandleTree){
        this.handle = el;
        this.hasHandleSet = true;
        // Make sure user didn't forget the secondParam and expect true.
        this.useHandleTree = setHandleTree != false;
    },
    
    /** @param {HTMLElement} target Element that is checked.
     * @private
     */
    isInHandle : function(target) {
         return target == this.handle || (this.useHandleTree && APE.dom.contains( this.handle, target ));
     },

    /** 
     * Adds a drop target.
     * @param {HTMLElement|APE.drag.DropTarget} dropTarget either an element or a DropTarget.
     * @return {DropTarget} The drop target that was added.
     */
    addDropTarget : function(dropTarget) {
        var DropTarget = APE.drag.DropTarget, 
            el = DropTarget.getByNode(dropTarget).el,
            dropTargets = this.dropTargets;
        if(this.el === el) return;
        return dropTargets[dropTargets.length] = DropTarget.getByNode(el);
    },
    
    /** 
     * Grabs the draggable, centering it under the cursor.
     * @param {Event} e the event to grab the element from.
     * @param {int} [xOffset] amount of horizontal adjustment to apply.
     * @param {int} [xOffset] amount of vertical adjustment to apply.
     */
    grab : function(e, xOffset, yOffset) {
        if(!e) e = event;
        
        var dom = APE.dom,
            Event = dom.Event,
            target = Event.getTarget(e),
            drag = APE.drag,
            DragHandlers = drag.DragHandlers;

        if(e.preventDefault) e.preventDefault();
        e.returnValue = false;
        
        if(dom.contains(this.el, target)) return;
         
        this._fixFocus(e);

        var grabCoords = dom.getPixelCoords(this.el);
        this.grabX = grabCoords.x;
        this.grabY = grabCoords.y;
        
        // Get the container's offset.
        var eventCoords = Event.getCoords(e),
            offsetCoords = dom.getOffsetCoords(dom.getContainingBlock(this.el)),
            offsetY = eventCoords.y - offsetCoords.y,
            newY = Math.floor(offsetY - (this.handle.offsetHeight/2)),
            handleOffsetCoords = dom.getOffsetCoords(this.handle, this.el),
            constraints = drag.Draggable.constraints;
        
        if(this.constraint != constraints.VERT) {
            // Center the dragObject around the coords, but keep it inside.

            var offsetX = eventCoords.x - offsetCoords.x,
                newX = offsetX - Math.floor((this.handle.offsetWidth/2));
            if(this.keepInContainer) {
                newX = Math.max(newX, 0);
                newX = Math.min(newX, this.container.clientWidth - this.el.offsetWidth);
            }

            this.moveToX(newX- handleOffsetCoords.x + (xOffset||0));
        }
        if(this.constraint != constraints.HORZ) {
            if(this.keepInContainer) {
                newY = Math.max(newY, 0);
                newY = Math.min(newY, this.container.clientHeight - this.el.offsetHeight);
            }
            this.moveToY(newY - handleOffsetCoords.y + (yOffset||0));
        }
        DragHandlers.dragObjGrabbed(e, this);    
        DragHandlers.dO = this;
    },
    
    /** 
     * Selects the draggable, adding selectedClassName
     * @param {boolean} isSelect if false, deselects.
     */
    select : function(isSelect) {
        var APE = window.APE, Draggable = APE.drag.Draggable;
        if(isSelect) {
            if(this.selectedClassName)
                APE.dom.addClass(this.el, this.selectedClassName);
            // onselect handler would go here, if/when needed. return false to prevent.
            if(this.dragMultiple && ! (this.id in Draggable.draggableList))
                Draggable.draggableList[this.id] = this;
        }
        else {
            if(this.selectedClassName)
                APE.dom.removeClass(this.el, this.selectedClassName);
            // ondeselect handler would go here, if/when needed.
            delete Draggable.draggableList[this.id];
        }
        this.isSelected = Boolean(isSelect);
    }

    /** 
     * @param {boolean} isFocus if false, blurs.
     * @param {Event} e the DOM event.
     */
    ,setFocus : function(isFocus, e) {

        if(isFocus == this.hasFocus) return; // nothing to do.

        if(!this.isDragEnabled) return false; 

        //console.log('set ' + this + ' focus = ' + isFocus);
        var ret = true,
            DragHandlers = APE.drag.DragHandlers, dom = APE.dom;
        if(isFocus) {
            if(this.focusClassName)
                dom.addClass(this.el, this.focusClassName);
            if(typeof this.onfocus == "function") { 
                ret = this.onfocus(e);
            }
            if(ret != false) {
                if(DragHandlers.focusedDO) {
                    DragHandlers.focusedDO.setFocus(false, e);
                }
                DragHandlers.focusedDO = this; // Should be afterAdvice.
            }
        }
        else {
            if(this.focusClassName)
                dom.removeClass(this.el, this.focusClassName);
            if(typeof this.onblur == "function") 
                ret = this.onblur(e);
            if(ret != false) DragHandlers.focusedDO = null; // Should be afterAdvice.
        }
        this.hasFocus = isFocus;
        return ret;
    },
    
    /** @private
     * @type {APE.drag.DropTarget[]|boolean}
     * @description An array of DropTarget that has one of: 
     * an ondragover or ondragout handler
     * a hoverClassName
     */
    _dragOverTargets : false,

    /** @private
     */
    _fixFocus : function(e) {
        // Mozilla will not give focus to/remove focus from an element when mouseDown returns false;
        // However, it is necessary to return false onmousedown to prevent selecting 
        // an img dragObj and having the browser "grab" it.
        //
        // IE won't set focus unless the element has tabIndex. 
        // Conditionally force focus when the element was clicked, regardless of IE/Moz anomalies.

        // Mozilla still won't get the focus event.
    

        var metaKey = e.metaKey || (/Win/.test(navigator.platform) && e.ctrlKey);
        if(!this.dragMultiple && APE.drag.DragHandlers.focusedDO && metaKey) return false;

        if(!this.hasFocus) {
            this.setFocus(!this.hasFocus, e);
        }
    },

    /** @private
     * grab will create a clone here, and will be released to here. 
     */
    dragStart : function(e) {

        if(this.isBeingDragged) return;

        if(this.dragCopy) {
            this.assignClone(e);  // this.el assigned to copyEl, this.origEl stays put.
        }
        if(typeof this.ondragstart == "function")
            this.ondragstart(e);

        if(this.activeDragClassName) 
            APE.dom.addClass(this.el, this.activeDragClassName);
        // Check the coords after making the copyEl here.
        APE.drag.DragHandlers.setUpCoords(e, this);
        this.isBeingDragged = true; 
    },
    
    /**
     * releases the draggable, as if the mouse had been released.
     * @param {Event} [e] the event that triggered release
     */
    release : function(e) {
        APE.drag.DragHandlers.dragObjReleased(e, this);
        if(typeof this.onrelease == "function") 
            this.onrelease(e);
    },

    /**@private
     * creates a copyEl for dragCopy
     */
    assignClone : function(e) {

        var dom = APE.dom, addClass = "addClass",
            copyEl,
            el = this.el,
            origEl = el,
            copyElStyle;

        if(!this.copyEl) {
            this.origEl = el;
            this.copyEl = el.cloneNode(true);
        }
        copyEl = this.copyEl;
        copyElStyle = copyEl.style;

        if(this.focusClassName) 
            dom[this.hasFocus ? addClass : "removeClass"](copyEl, this.focusClassName);
        
        copyElStyle.display = "";

        if(copyEl.parentNode != el.parentNode)
        // In case the element was appened elsewhere, by external script
            el.parentNode.insertBefore(copyEl, el);

        // 100 draggable items appear above.
        copyElStyle.zIndex = parseInt(origEl.style.zIndex) + 100;
        if(this.origClassName)
            dom[addClass](el, this.origClassName);
        
        this.el = copyEl;
        this.style = copyElStyle;

        // This helps prevent copyEl from displacing other elements.
        if(this.isRel) {
            copyElStyle.marginBottom = -origEl.offsetHeight + 
                -(parseInt(dom.getStyle(origEl, "marginBottom"))||0) + "px";
            copyElStyle.marginright = -origEl.offsetWidth + 
                -(parseInt(dom.getStyle(origEl, "marginRight"))||0) + "px";
        }
    },

    /** @private
     */
    retireClone : function() {
        // this causes IE to lose focus, then fire focus for another element (IE decides which one).
        this.constructor.focused.timeStamp = new Date;

        if(this.copyEl.style.display == "none") return;
    
        this.el = this.origEl;
        this.style = this.origEl.style;
        
        // Update position of origEl, which was left behind.
        this.moveToX(this.x);
        this.moveToY(this.y);
        this.copyEl.style.display = "none";
        if(this.origClassName)
            APE.dom.removeClass(this.el, this.origClassName);
    },

    moveToX : ('pixelLeft'in document.documentElement.style ? 
        function(x) { this.style.pixelLeft = this.x = x; } :
        function(x) { this.style.left = (this.x = x) + "px"; }),
    
    moveToY : ('pixelTop'in document.documentElement.style ? 
        function(y) { this.style.pixelTop = this.y = y;} :
        function(y) { this.style.top = (this.y = y) + "px";}),

    moveToXY : ('pixelTop'in document.documentElement.style ? 
        function(x,y) { 
            var s = this.style;
            s.pixelLeft = this.x = x;
            s.pixelTop = this.y = y;
        } :
        function(x,y) {
            var s = this.style;
            s.left = (this.x = x) + "px";
            s.top = (this.y = y) + "px";
    }),
    
    /**
     * @private
     */
    glideStart : function(x, y) {
    // Would be cleaner to separate this concern; APE.drag.Draggable should not have to concern itself 
    // for animation and notifying subscribers of onglide, et c.

        if(this.animTimer) return;
        
        this.startX = x;
        this.startY = y;

        var dx = this.startX - this.grabX,
            dy = this.startY - this.grabY;

        // Calculate Hypoteneuse.
        this.GlideDist = Math.ceil(Math.sqrt((dx * dx) + (dy * dy)));

        if(this.GlideDist === 0) return;
        this.rx = Math.abs(dx)/this.GlideDist;
        this.ry = Math.abs(dy)/this.GlideDist;
        if(this.x > this.grabX)
            this.rx = -this.rx;
        if(this.y > this.grabY)
            this.ry = -this.ry;
        
        this.startTime = new Date().getTime();
        this.animTimer = window.setInterval("APE.drag.Draggable.instances['"+this.id+"'].glide()", 10);
    },

    /**
     * @private
     */
    glide : function() {
        var t = new Date - this.startTime,
        // 2px per 10ms slight acceleration 10px/s
            d = Math.ceil(2 * t + .5 * .01 * t*t);

        if(d >= this.GlideDist) {
            this.animTimer = clearInterval(this.animTimer);
            if(this.constraint != 2) 
                this.moveToX( this.grabX );
            if(this.constraint != 1)
                this.moveToY( this.grabY );
            if(this.copyEl) {
                this.el = this.origEl;
                this.style = this.origEl.style;
                this.copyEl.style.display = "none";
            }
            if(typeof this.onglide == "function")
                this.onglide();
            if(typeof this.onglideend == "function")
                this.onglideend();
            this.dragDone({});
        }
        else {
            if(this.constraint != 2) 
                 this.moveToX(this.startX + d * this.rx);
            if(this.constraint != 1)
                 this.moveToY(this.startY + d * this.ry);
            if(typeof this.onglide == "function")
                this.onglide();
        }
    },
    
    /** Starts gliding the draggable back to its original x,y coords. 
     * @param {Number} [x] x coordinate to start gliding from.
     * @param {Number} [y] y coordinate to start gliding from.
     */
    animateBack : function(x, y) {
         this.glideStart(x||this.x, y||this.y);
    },
    
    /** A dragObj does not check search for containing block each time its grabbed/dragged.
     * Instead, it reuses the container. If the container must change, this must be done 
     * manually, via dragObj.setContainer(newEl);
     */
    setContainer : function(el) {
        this.container = el;
    },
    
    /** 
     * Removes a drop target.
     * @param {HTMLElement|DropTarget} element or DropTarget to remove.
     * @return {HTMLElement} the removed dropTarget element.
     */
    removeDropTarget : function(el){
        el = document.getElementById(el.id); 
        
        for(var i = 0, len = this.dropTargets.length; i < len; i++) {
            if(this.dropTargets[i].el === el) {
                this.dropTargets.splice(i, 1);
                return el;
            }
        }
        return null;
    },
    
    /** 
     * @fires this.ondragend()
     * fires the ondragend handler.
     */
    dragDone : function(e) {
        if(this.activeDragClassName)
            APE.dom.removeClass(this.el, this.activeDragClassName);
        if(typeof this.ondragend == "function" && this.hasBeenDragged) {
            this.ondragend(e);
        }
        if(this.copyEl) { // in case user does some appending of el, et c.
            this.el.parentNode.insertBefore(this.copyEl, this.el);
        }
        this.hasBeenDragged = false;
    },
    
    // For some browsers (IE and Safari), the currentStyle/computedStyle 
    // for top/left will be "auto" when bottom and right values are set.
    _setIeTopLeft : function() { 
        // For IE, set top/left values when declared values are auto
        // and right/bottom values are given.

        var dv = document.defaultView,
            el = this.el,
            s = el.style,
            cs = el.currentStyle || 
            (dv.getComputedStyle && dv.getComputedStyle(el,"")) || s,
            cb = APE.dom.getContainingBlock(el),
            curL = cs.left, 
            curR = cs.right, 
            curT = cs.top,
            curB = cs.bottom;        
        // Calculate left when right is given pixel value and left is "auto".
        if((curL == "" || curL == "auto")) {
            curR = parseInt(curR);
            if(isFinite(curR))
                s.left = cb.clientWidth - el.offsetWidth - curR + "px";
            else s.left = "0";
        }

        // Calculate top when bottom is given pixel value and top is "auto".
        if((curT == "" || curT == "auto")) {
            curB = parseInt(curB);
            if(isFinite(curB)) {
                s.top = cb.clientHeight - el.offsetHeight - curB + "px";
            }
            else s.top = "0";
        }
    },

    toString : function() { return "APE.drag.Draggable(id=" +this.id +")"; }
};

/** @type {Number} 
 * @description a higher z-index is assigned beforedragstart. */
APE.drag.Draggable.highestZIndex = 1000;

/** @type {Object} 
 * @description Internal map of draggables */
APE.drag.Draggable.draggableList = { };

/** 
 * called before dragstart, this function checks to see if there are any droptargets 
 * that need mousemove consideration. For example, if the droptarget has a
 * dragOverClassName, or has an ondragover handler.
 * @private
 */
APE.drag.Draggable._setUpDragOver = function(dO) {
    // subset for ondragover, to help speed up dragging 
    // with multiple drop targets.
    dO._dragOverTargets = [];
    var dropTargets = dO.dropTargets,
        dt