/**
 * XoxoOutliner
 *
 * TODO: Implement click/move/time thresholds for drag like YUI DnD
 * TODO: Stash some undo revisions in persistent client storage?
 * TODO: Reversable DOM modification commands, for more granular undo.
 * TODO: Yank out all the hardcoded CSS class names, stick into constants.
 */
if (!window.XoxoOutliner) window.XoxoOutliner = {};

Function.prototype.bind = function(obj) {
    var method = this;
    return function() {
        return method.apply(obj, arguments);
    };
} 

DOMUtils = function() {
    var Dom = YAHOO.util.Dom;
    var Evt = YAHOO.util.Event;

    return {
        // See: http://simon.incutio.com/archive/2003/06/15/javascriptWithXML
        XHTML_NS: 'http://www.w3.org/1999/xhtml',
        createElement: function(el) {
            var self = arguments.callee;
            if (!self.createElement) {
                if (typeof document.createElementNS != 'undefined') {
                    self.createElement = function(el) { return document.createElementNS(this.XHTML_NS, el); };
                }
                if (typeof document.createElement != 'undefined') {
                    self.createElement = function(el) { return document.createElement(el); };
                }
            }
            return self.createElement(el);
        },

        // {{{ parentNodeByTagName
        parentNodeByTagName: function(ele, tn, top) {
            var curr = ele;
            tn = tn.toLowerCase();
            while ( (curr = curr.parentNode) && (curr != top) ) 
                if (curr.tagName && curr.tagName.toLowerCase() == tn)
                    return curr;
            return null;
        },
        // }}}
        
        firstChildByTagName: function(ele, tn) {
            var curr = ele.firstChild;
            tn = tn.toLowerCase();
            while (curr) {
                if (curr.tagName && curr.tagName.toLowerCase() == tn)
                    return curr;
                curr = curr.nextSibling;
            }
        },
        
        lastChildByTagName: function(ele, tn) {
            var curr = ele.lastChild;
            tn = tn.toLowerCase();
            while (curr) {
                if (curr.tagName && curr.tagName.toLowerCase() == tn)
                    return curr;
                curr = curr.previousSibling;
            }
        },
        
        nextSiblingByTagName: function(ele, tn) {
            var curr = ele;
            tn = tn.toLowerCase();
            while (curr = curr.nextSibling)
                if (curr.tagName && curr.tagName.toLowerCase() == tn)
                    return curr;
            return null;
        },

        previousSiblingByTagName: function(ele, tn) {
            var curr = ele;
            tn = tn.toLowerCase();
            while (curr = curr.previousSibling)
                if (curr.tagName && curr.tagName.toLowerCase() == tn)
                    return curr;
            return null;
        },

        appendListChild: function(parent_li, child_li) {
            var uls = parent_li.getElementsByTagName('ul');
            var parent_ul;
            if (uls.length) {
                parent_ul = uls[0];
            } else {
                parent_ul = this.createElement('ul');
                parent_li.appendChild(parent_ul);
            }
            parent_ul.appendChild(child_li);
        },

        trim: function(sInString) {
            if (!sInString) return '';
            sInString = sInString.replace( /^\s+/g, "" );// strip leading
            return sInString.replace( /\s+$/g, "" );// strip trailing
        },

        EOF:null
    };
}();

/**
 * Singleton manager for all outliners on a page.
 */
XoxoOutliner.OutlinerManager = function() {
    return this.init();
};
XoxoOutliner.OutlinerManager.getInstance = function() {
    if (!XoxoOutliner.OutlinerManager.instance) 
        XoxoOutliner.OutlinerManager.instance = new XoxoOutliner.OutlinerManager();
    return XoxoOutliner.OutlinerManager.instance;
};
XoxoOutliner.OutlinerManager.prototype = function() {
    var Dom = YAHOO.util.Dom;
    var Evt = YAHOO.util.Event;

    return {
        /*
            Define a few named keys to be handled here.
        */
        KEY_NAMES: {
            8:  'BS',
            9:  'TAB',
            13: 'RETURN',
            77: 'RETURN',
            27: 'ESC',

            32: 'SPACE',
            33: 'PGUP',
            34: 'PGDN',
            35: 'END',
            36: 'HOME',
            37: 'LEFT',
            38: 'UP',
            39: 'RIGHT',
            40: 'DOWN',

            // For Safari keypress event:
            63232: 'UP',
            63233: 'DOWN', 
            63234: 'LEFT',
            63235: 'RIGHT'
        },
        _log_cache: {},

        /*
            Initialize a global outliner manager.
        */
        init: function() {
            this.outliners = [];
            this.selected_outliner = null;

            var caret = DIV({'id':'outliner_caret', 'class':'outliner_hidden'},' ');
            document.body.insertBefore(caret, document.body.firstChild);

            this.editor = new XoxoOutliner.OutlineItemEditor(this);

            var key_evt_name = (window.addEventListener) ? 'keypress' : 'keydown';
            Evt.on(window, key_evt_name,   this.onKeyDispatch, this, true);
            Evt.on(window, 'unload',       this.destroy,     this, true);
            Evt.on(window, 'resize',       this._onResize,     this, true);
        },

        edit: function(el, outliner) {
            this.editor.edit(el, outliner);
        },

        /*
            Offer a string representation of this instance.
        */
        toString: function() { 
            return "OutlinerManager";
        },

        /*
            Get a logger configured for this object, customized for method.
        */
        getLog: function(cat) {
            if (!this._log_cache[cat])
                this._log_cache[cat] = function(msg, lvl) {
                    YAHOO.log(msg, (lvl || "debug"), "OutlinerManager:"+cat);
                };
            return this._log_cache[cat];
        },

        /*
        */
        _onResize: function(e) {
            for (var i=0, o; o=this.outliners[i]; i++) o.outlineChanged();
        },

        /*
            Handle destroying any memory leaky crud we've got laying around.
        */
        destroy: function(e,me) {
            for (var i=0, o; o=this.outliners[i]; i++) o.destroy();
            this.outliners = [];
        },

        /*
            Register a newly instantiated outliner with the manager.
        */
        registerOutliner: function(o){
            this.outliners.push(o);
        },

        /*
            Change the current selected outliner, deselecting the previous, if
            any.
        */
        select: function(o) {
            var log = this.getLog('select');
            if (this.selected_outliner && this.selected_outliner != o)
                this.selected_outliner.onDeselected();
            this.selected_outliner = o;
            o.onSelected();
        },

        /*
            Preprocess and dispatch keyboard events to named sub-handlers in an
            active editor or selected outline.
        */
        onKeyDispatch: function(evt) {
            var log = this.getLog('onKey');

            // Do nothing if no outliner is selected.
            if (!this.selected_outliner) return true;

            var which = evt.which;
            var keycode = evt.keyCode;
            var charcode = Evt.getCharCode(evt);
            var meth_name  = 'onKey';

            // Assemble a method name from modifiers, alpha char, and named keys.
            if (evt.ctrlKey)  meth_name += '_CTRL';
            if (evt.shiftKey) meth_name += '_SHIFT';
            if (evt.altKey)   meth_name += '_ALT';
            if (evt.metaKey)  meth_name += '_META';
            if ( keycode == 0 && (
                    (charcode >= 48 && charcode <= 57) || 
                    (charcode >= 97 && charcode <= 122) ||
                    (charcode >= 65 && charcode <= 90) 
               ) ) {
                meth_name += '_' + String.fromCharCode(charcode).toUpperCase();
            } else {
                meth_name += '_' + ( (this.KEY_NAMES[keycode]) ? this.KEY_NAMES[keycode] : keycode );
            }

            // Allow the editor (if active), followed by the current outliner,
            // to each get a shot at handling the keypress.
            var rv = true;
            var outliner = this.selected_outliner;
            if (this.editor.isActive() && this.editor[meth_name]) {
                rv = this.editor[meth_name](evt);
            } else if (this.editor.isActive() && this.editor['onKey']) {
                rv = this.editor.onKey(evt);
            } else if (outliner && outliner[meth_name]) {
                rv = outliner[meth_name](evt);
            } else if (outliner && outliner['onKey']) {
                rv = outliner.onKey(evt);
            } else {
                // log(meth_name);
            }

            // If the key handler method returned false, do not pass it along.
            if (!rv) {
                Evt.stopEvent(evt);
                evt.preventDefault();
                return false;
            }
            
            // Key event was not handled, so pass it along.
            return true;
        },

        EOF:null
    };
}();

/**
 * Manager of a single outliner on a page.
 */
XoxoOutliner.Outliner = function(id_or_ele) {
    return this.init(id_or_ele);
};
XoxoOutliner.Outliner.prototype = function() {
    var Dom = YAHOO.util.Dom;
    var Evt = YAHOO.util.Event;

    return {
        
        RESCAN_DELAY: 250, // This is a compromise for UI response
        MAX_UNDO_REVISIONS: 50, // TODO: need a sane value here.
        OUTLINER_HANDLE_WIDTH: 20, // This should be related to CSS left-padding
        LANDING_LEFT_WIDTH: 40, // This should be related to CSS left-padding
        REGION_PAD: 2, // This should be related to CSS margins?

        _log_cache: {},

        /*
            Initialize this outline handler.
        */
        init: function(id_or_ele) {
            var log = this.getLog('init');

            // Register ourselves with the global manager.
            this.manager = XoxoOutliner.OutlinerManager.getInstance();
            this.manager.registerOutliner(this);

            // Grab the root element, and get its ID.  Generate one if need be.
            var root_ele = Dom.get(id_or_ele);
            this.root    = root_ele;
            this.root_id = root_ele.id || Evt.generateId(root_ele);

            // Clear the stack of revisions to undo / redo
            this.undo_revisions = [];
            this.redo_revisions = [];

            // Cache the node regions before doing anything else.
            this._performOutlineRescan(true, true);

            // Attach all the outliner UI event handlers.
            // Evt.on(this.root_id, 'dblclick',  this.onDoubleClick, this, true);
            Evt.on(this.root_id, 'mousedown', this.onMouseDown,   this, true);
            Evt.on(this.root_id, 'mousemove', this.onMouseMove,   this, true);
            Evt.on(this.root_id, 'mouseup',   this.onMouseUp,     this, true);
        },

        onDoubleClick: function(e) {
            var log = this.getLog("onDoubleClick");

            log("HIT!");
        },

        /*
            Offer a string representation of this instance.
        */
        toString: function() { 
            return "Outliner["+this.root_id+"]"; 
        },

        /*
            Handle destroying any memory leaky crud we've got laying around.
            TODO: Make this not voodoo.  Understand what needs to be blown away and how.
        */
        destroy: function(e,me) {
            this.region_cache = [];
            this.item_selected = null;
            if (this.curr_drag) {
                this.curr_drag.el = null;
                this.curr_drag = null;
            }
            this.el_hit = null;
            this.root = null;
        },

        /*
            Get a logger configured for this object, customized for method.
        */
        getLog: function(cat) {
            if (!this._log_cache[cat])
                this._log_cache[cat] = function(msg, lvl) {
                    YAHOO.log(msg, (lvl || "debug"), "Outliner:"+cat);
                };
            return this._log_cache[cat];
        },
        log: function(msg, lvl) {
            YAHOO.log(msg, (lvl || "debug"), "Outliner["+this.root_id+"]");
        },

        /*
            Perform housekeeping necessary upon outline structural changes.
        */
        outlineChanged: function(do_undo, adjust_handles) {
            var log = this.getLog("outlineChanged");

            // If a scan's already scheduled, defer it further...
            if (this._scheduled_scan)
                window.clearTimeout(this._scheduled_scan);

            // HACK: Deferred action to promote teh UI snappy
            this._scheduled_scan = window.setTimeout(function() { 
                this._performOutlineRescan(do_undo, adjust_handles); 
            }.bind(this), this.RESCAN_DELAY);
        },

        _performOutlineRescan: function(do_undo, adjust_handles) {
            var log = this.getLog("_performOutlineRescan");

            // Start with a fresh region cache list.
            var regions = [];

            // Scan all the list item elements in the outline.
            var nodes = Dom.get(this.root_id).getElementsByTagName('li');
            for (var i=0,node; node=nodes[i]; i++) {
                
                // Get the region for this node, pad it, stash it in the cache.
                var r = Dom.getRegion(node);
                r.top    -= this.REGION_PAD; 
                r.bottom += this.REGION_PAD; 
                r.left   -= this.REGION_PAD; 
                r.right  += this.REGION_PAD;
                regions.push([r, node, r.getArea()]);

                if (adjust_handles) {
                    
                    // HACK: Upon rewind from undo, adjust the current selection based on class
                    if (do_undo==false && Dom.hasClass(node, 'outliner_item_selected'))
                        this.selectItem(node);
                    // Or is it better to just to clear the selection altogether?
                    //if (do_undo==false && node != this.item_selected)
                    //    Dom.removeClass(node, "outliner_item_selected");

                    this.adjustHandleClasses(node);

                    // if adjusting handles, re-edit the actively edited node.
                    if (this.manager.editor.isActiveEditing(node)) 
                        this.manager.edit(node, this);
                }
            }

            // Stow the accumulated region cache. 
            this.region_cache = regions;

            // Preserve the current state in the undo stack.
            if (do_undo != false) this.takeSnapshotForUndo();
        },

        /*
            Stash the present state of the outline in the undo stack.
            TODO:  This is a sledgehammer of an approach.  Find a better one.
        */
        takeSnapshotForUndo: function() {

            // Push the current innerHTML source of outline onto the stack.
            this.undo_revisions.push(Dom.get(this.root_id).innerHTML);

            // Limit stack size by snipping at the front of the stack.
            if (this.undo_revisions.length > this.MAX_UNDO_REVISIONS)
                this.undo_revisions.shift();

            // Clear the redo stack, since each new change invalidates it.
            this.redo_revisions = [];
        },

        /*
            Roll back the outline to its most recent previous state.
            Return value is whether anything was actually done.
            TODO:  This is a sledgehammer of an approach.  Find a better one.
        */
        undo: function() {
            var log = this.getLog("undo");

            this.manager.editor.clear();

            // Current state of outline is always the last element in undo stack, 
            // thus it'll never be totally empty.
            if (this.undo_revisions.length <= 1) return false;

            // Shove the current state onto the redo stack, roll state back to 
            // previous undo level.
            this.redo_revisions.push(this.undo_revisions.pop());
            Dom.get(this.root_id).innerHTML = 
                this.undo_revisions[this.undo_revisions.length-1];

            // Trigger an outline rescan, without undo support.
            this.outlineChanged(false, true);
            return true;
        },

        /*
            Roll back the roll back caused by an undo.
            Return value is whether anything was actually done.
            TODO:  This is a sledgehammer of an approach.  Find a better one.
        */
        redo: function() {
            var log = this.getLog("redo");

            // Redo does nothing if the stack is empty.
            if (this.redo_revisions.length < 1) return false;

            // Roll the last undo back onto the stack, restore that state to
            // the outline.
            this.undo_revisions.push(this.redo_revisions.pop());
            Dom.get(this.root_id).innerHTML = 
                this.undo_revisions[this.undo_revisions.length-1];

            // Trigger an outline rescan, without undo support.
            this.outlineChanged(false);
            return true;
        },

        /*
            Handle being selected by the global manager.    
        */
        onSelected: function() {
        },

        /*
            Handle being deselected by the global manager.
        */
        onDeselected: function() {
        },
    
        /*
            Handle mouse down event on outline items.
        */
        onMouseDown: function(e) {
            var log = this.getLog('onMouseDown');
            var el = Evt.getTarget(e);

            // Make this outliner the current selection.
            this.manager.select(this);

            // Hold your horses, there's an item still on its way home!
            if (this.snap_back) return;

            // If there was already a drag in progress, try to unwedge things.
            if (this.curr_drag) return this.onMouseUp(e);

            // If the click is on a non-link child of an item, pop up to the containing list item.
            //if (el.nodeName.toLowerCase() != 'li') return;
            while (el && el.id != this.root_id && el.nodeName.toLowerCase() != 'li')
                el = el.parentNode;

            // Grab the positions of the clicked element and the click event.
            var el_xy = Dom.getXY(el);
            var ev_xy = Evt.getXY(e);

            // Prepare recordkeeping for the current drag.
            this.curr_drag = {
                orig_pos: el_xy,
                offset_x: ev_xy[0] - el_xy[0],
                offset_y: ev_xy[1] - el_xy[1],
                did_drag: false,
                el: el
            };

            // Highlight and select this clicked item.
            this.selectItem(this.curr_drag.el);

            // Allow selection without editing, depending on click in handle or body of item.
            if (! this.isClickInHandle(ev_xy, this.curr_drag.el)) {
                // If the click is in the body of the item, activate the editor.
                this.manager.editor.edit(el, this);
            } else {
                // If the click is on the handle, commit and deactivate the editor.
                this.manager.editor.commit();
            }

            Evt.stopEvent(e);
            e.preventDefault();
            return false;
        },

        /*
            Handle mouse movement events.
        */
        onMouseMove: function(e) {
            var log = this.getLog('onMouseMove');

            // Do nothing if there's not a current drag in progress.
            if (!this.curr_drag) return;

            // Record the fact that the mouse down resulted in a drag.
            this.curr_drag.did_drag = true;
            
            Dom.addClass(this.curr_drag.el, 'outliner_item_dragging');

            // Scan the cached regions for collision with the event point.
            var el     = this.curr_drag.el;
            var ev_xy  = Evt.getXY(e);
            var el_hit = this.findElementAt(ev_xy, el);

            if (el_hit) this.positionLandingPreview(ev_xy, el_hit);

            // Calculate the new item position, accounting for autoscrolling.
            var pos = [ev_xy[0] - this.curr_drag.offset_x, ev_xy[1] - this.curr_drag.offset_y];
            this.autoScroll(pos[0], pos[1], el.offsetHeight, el.offsetWidth);
            Dom.setXY(el, pos);

            Evt.stopEvent(e);
        },

        /*
            Handle mouse release event.
        */
        onMouseUp: function(e) {
            var log = this.getLog('onMouseUp');

            // Do nothing if there's not a current drag in progress.
            if (!this.curr_drag) return;

            // Clear the current drag visual effects, if any.
            this.curr_drag.el.style.position = null;
            Dom.removeClass(this.curr_drag.el, 'outliner_item_dragging');
            Dom.replaceClass('outliner_caret', 'outliner_shown', 'outliner_hidden');

            // If no drag actually happened, treat this as a click.
            if (!this.curr_drag.did_drag) {
                return this.onMouseClick(e)
            }

            var ev_xy  = Evt.getXY(e);
            var el_hit = this.findElementAt(ev_xy, this.curr_drag.el);
            if (el_hit) {
                // Find a landing site for this node.
                var landing = this.determineMouseLandingCase(ev_xy, el_hit);
                this.performLanding(this.curr_drag.el, el_hit, landing);
                this.selectItem(this.curr_drag.el);
            }

            this.curr_drag = null;
            Evt.stopEvent(e);
            return false;
        },

        /*
            Handle mouse up/down click without a drag.
        */
        onMouseClick: function(e) {
            var log = this.getLog("onOutlineClick");
            var el = this.curr_drag.el; // Evt.getTarget(e);

            if (el.nodeName.toLowerCase() == 'li')
                this.selectItem(el);

            // Is this a click inside an outliner parent?
            if (this.findChildListContainer(el)) {
                if (this.isClickInHandle(Evt.getXY(e), el)) {
                    // Bingo, within a on, fire off a toggle and stop event propagation.
                    this.toggleItem(el);
                    Evt.stopEvent(e)
                }
            }

            this.curr_drag = null;
        },

        /*
            Given a hit x/y and an element, determine whether the point is
            within the element's handle area.
        */
        isClickInHandle: function(xy, el) {
            var handle_pad = this.getHandlePadding(el);
            var el_x = Dom.getX(el);
            return ((xy[0] - el_x) < handle_pad);
        },

        /*
            Up: Select the previous item in linear order.
        */
        onKey_UP: function(e) {
            var prev = this.findPrev(this.item_selected);
            if (prev) {
                this.selectItem(prev); return false;
            } else {
                return true;
            }
        },

        /*
            Down: Select the next item in linear order.
        */
        onKey_DOWN: function(e) {
            var next = this.findNext(this.item_selected);
            if (next) {
                this.selectItem(next); return false;
            } else {
                return true;
            }
        },

        /*
            Left: Select current item's parent.
        */
        onKey_LEFT: function(e) {
            var list_parent = this.item_selected.parentNode;
            if (list_parent != Dom.get(this.root_id)) {
                this.selectItem(list_parent.parentNode);
                return false;
            }
            return true;
        },

        /*
            Right: Select current item's first child.
        */
        onKey_RIGHT: function(e) {
            var el = this.item_selected;
            
            // Expand this item, if it's collapsed
            this.toggleItem(el, true);

            var list = this.findChildListContainer(el);
            if (list) {
                this.selectItem(DOMUtils.firstChildByTagName(list, "li"));
                return false;
            }
            return true;
        },

        /*
            Ctrl-Left: If on a leaf, pop up to parent and collapse it.  If on a
            parent, collapse.
        */
        onKey_CTRL_LEFT: function(e) {
            var el = this.item_selected;
            if (this.findChildListContainer(el)) {
                this.toggleItem(el, false);
                return false;
            } else {
                var list_parent = this.item_selected.parentNode;
                if (list_parent != Dom.get(this.root_id)) {
                    this.toggleItem(list_parent.parentNode, false);
                    this.selectItem(list_parent.parentNode);
                    return false;
                }
            }
            return true;
        },

        /*
            Ctrl-Right: If a parent is selected, expand it.
        */
        onKey_CTRL_RIGHT: function(e) {
            var el = this.item_selected;
            if (this.findChildListContainer(el)) {
                this.toggleItem(el, true);
                return false;
            }
            return true;
        },

        /*
            Ctrl-Up: If on a parent, collapse all child items that are parents.
        */
        onKey_CTRL_UP: function(e) {
            var el = this.item_selected;
            var child_list = this.findChildListContainer(el);
            if (child_list) {
                var lis = child_list.getElementsByTagName('li');
                forEach(lis, function(li) { this.toggleItem(li, false); }, this);
                return false;
            }
            return true;
        },

        /*
            Ctrl-Down: If on a parent, expand all child items that are parents.
        */
        onKey_CTRL_DOWN: function(e) {
            var el = this.item_selected;
            var child_list = this.findChildListContainer(el);
            if (child_list) {
                var lis = child_list.getElementsByTagName('li');
                forEach(lis, function(li) { this.toggleItem(li, true); }, this);
                return false;
            }
            return true;
        },

        /*
            Ctrl-Z, Cmd-Z: Perform undo.
        */
        onKey_CTRL_Z: function(evt) { 
            return this.onKey_META_Z(evt); 
        },
        onKey_META_Z: function(evt) {
            return !this.undo();
        },

        /*
            Shift-Up: Move selected item before previous sibling.
        */
        onKey_SHIFT_UP: function(evt) {
            if (!this.item_selected) return true;
            var target = DOMUtils.previousSiblingByTagName(this.item_selected, 'li');
            if (!target) return true;
            this.performLanding(this.item_selected, target, { before: true, child: false });
            return false;
        },

        /*
            Shift-Down: Move selected item after next sibling.
        */
        onKey_SHIFT_DOWN: function(evt) {
            if (!this.item_selected) return true;
            var target = DOMUtils.nextSiblingByTagName(this.item_selected, 'li');
            if (!target) return true;
            this.performLanding(this.item_selected, target, { before: false, child: false });
            return false;
        },
        
        /*
            Shift-Left: Move selected item to a sibling after current parent.
        */
        onKey_SHIFT_TAB: function(evt) { 
            this.onKey_SHIFT_LEFT(evt); 
        },
        onKey_SHIFT_LEFT: function(evt) {
            if (!this.item_selected) return true;
            var target = this.item_selected.parentNode.parentNode;
            this.performLanding(this.item_selected, target, { before: false, child: false });
            return false;
        },

        /*
            Shift-Left: Move selected item to last child of previous sibling.
        */
        onKey_TAB: function(evt) { 
            this.onKey_SHIFT_RIGHT(evt); 
        },
        onKey_SHIFT_RIGHT: function(evt) {
            if (!this.item_selected) return true;
            var target = DOMUtils.previousSiblingByTagName(this.item_selected, 'li');
            if (!target) return true;
            this.performLanding(this.item_selected, target, { before: false, child: true });
            return false;
        },

        /*
            Return: No item being edited, if this fires - so start editing
            current item.
        */
        onKey_RETURN: function(evt) {
            if (this.item_selected) 
                this.manager.editor.edit(this.item_selected, this);
        },

        /*
            Backspace: Delete the current selection.
        */
        onKey_BS: function(evt, is_delete) { 

            var parent_node = this.item_selected.parentNode;

            // If the selected item is the only item left, don't delete it.
            // TODO: Find a better response to this?
            if (parent_node == this.root && 
                this.root.getElementsByTagName('li').length == 1)
                return;

            // Attempt to select the deleted item's next sibling, prev sibling,
            // or parent - whichever exists.
            var new_target;
            if (!is_delete)
                new_target = DOMUtils.nextSiblingByTagName(this.item_selected, 'li');
            if (!new_target)
                new_target = DOMUtils.previousSiblingByTagName(this.item_selected, 'li');
            if (!new_target && parent_node != this.root)
                new_target = parent_node.parentNode;

            // Remove the selected item, select the new one, and update the outline.
            parent_node.removeChild(this.item_selected);
            if (parent_node.getElementsByTagName('li').length == 0)
                parent_node.parentNode.removeChild(parent_node);

            if (new_target) 
                this.selectItem(new_target);

            this.outlineChanged(true, true);

            if (is_delete)
                this.manager.editor.edit(new_target, this);

            parent_node = null;

            return false; 
        },

        /*
            Given an [x,y] tuple, scan regions cache for an element found there.

            TODO: Improve this to reduce search time?
        */
        findElementAt: function(xy, over_el) {
            var ev_r = new YAHOO.util.Point(xy);
            var min_area = -1, el_hit = null, regions = this.region_cache;

            for (var i=0, rc; rc = regions[i]; i++) {

                if ( 
                        // Ensure this is not the element being dragged.
                        //(rc[1] != over_el) && 
                        // Ensure that the current region contains the point.
                        rc[0].contains(ev_r) &&
                        // Prefer the region with the smallest area.
                        ( min_area==-1 || rc[2] < min_area ) &&
                        // And make sure that this is not actually a child
                        // element of the dragged element.
                        !Dom.isAncestor(over_el, rc[1]) 
                    ) {
                    min_area = rc[0].getArea();
                    el_hit   = rc[1];
                }

            }
            return el_hit;
        },

        /*
            Given a list item, look for either a UL or OL child.
        */
        findChildListContainer: function(el) {
            var uls = el.getElementsByTagName('ul');
            if (uls.length > 0) return uls[0];

            var ols = el.getElementsByTagName('ol');
            if (ols.length > 0) return ols[0];

            return null;
        },

        /*
            Given an item, attempt to find the next one in linear order.
        */
        findNext: function(ele) {

            // First, look for first item of an expanded child list.
            var child_list = this.findChildListContainer(ele);
            if (!Dom.hasClass(ele, "outliner_item_collapsed") && child_list) {
                return DOMUtils.firstChildByTagName(child_list, "li");
            }
            
            // Next, look for the next sibling list item.
            var after = DOMUtils.nextSiblingByTagName(ele, "li");
            if (after) return after;
            
            // Finally, pop up to through parents, looking for next list item.
            else {
                var root = Dom.get(this.root_id);
                for (var curr=ele.parentNode; curr && curr!=root; curr=curr.parentNode) {
                    if (curr.tagName.toLowerCase() == "li") {
                        var next = DOMUtils.nextSiblingByTagName(curr, "li");
                        if (next) return next;
                    }
                }
            }
            
            return null;
        },

        /*
            Given an item, attempt to find the previous one in linear order.
        */
        findPrev: function(ele) {

            // Look for prev sibling
            var prev_node = DOMUtils.previousSiblingByTagName(ele, "li");
            
            // If no previous sibling, pop up to parent
            if (!prev_node) {
                // There is no previous if this is the first child of root.
                if (ele.parentNode == Dom.get(this.root_id)) return null;

                prev_node = DOMUtils.parentNodeByTagName(ele, "li", Dom.get(this.root_id));
                if (prev_node) return prev_node;
            } else {
                // Dig down through any expanded children of prev node, to last leaf node.
                while (prev_node) {
                    var child_list = this.findChildListContainer(prev_node);
                    if (Dom.hasClass(prev_node, "outliner_item_collapsed") || !child_list) {
                        return prev_node;
                    }
                    prev_node = DOMUtils.lastChildByTagName(child_list, "li");
                }
            }

            return null;
        },

        /*
            Perform whatever feedback and record keeping necessary to track a
            selected outline item.
        */
        selectItem: function(el) {
            var log = this.getLog("selectItem");
            
            if (this.item_selected) {
                Dom.removeClass(this.item_selected, "outliner_item_selected");
            }
            this.item_selected = el;

            if (el) {
                Dom.addClass(el, "outliner_item_selected");

                // Autoscroll if need be, especially for keyboard access.
                var r  = Dom.getRegion(el);
                this.autoScroll(r.left+40, r.top+40, ( r.bottom - r.top ) + 80, (r.right - r.left) + 80);

                // If the editor is already active, update positioning & etc.
                if (this.manager.editor.isActive()) 
                    this.manager.edit(el, this);
            }
        },

        /*
            Given a mouse position and a hit element, work out the landing case
            for the dragged item. (ie. before / after, sibling / child)
        */
        determineMouseLandingCase: function(ev_xy, el_hit) {
            var log = this.getLog("determineMouseLandingCase");

            // Grab the offset from upper-left corner of hit element to mouse
            // position.
            var r          = Dom.getRegion(el_hit);
            var offset_x   = ( ev_xy[0] - r.left );
            var offset_y   = ( ev_xy[1] - r.top );

            // Figure out what quadrant the mouse is in on the hit element.
            var hit_width  = this.LANDING_LEFT_WIDTH; //( (r.right - r.left) / 10);
            var hit_height = ( (r.bottom - r.top) / 2);
            
            var child  = ( offset_x > hit_width );
            var before = ( offset_y < hit_height );

            // Is the hit item the first in the potential new list?
            var siblings = el_hit.parentNode.getElementsByTagName('li');
            var is_first_item = 
                ( el_hit == siblings[0] ) ||
                ( this.curr_drag.el == siblings[0] && el_hit == siblings[1] );

            // Don't allow before-child case where the item is the first in
            // the new potential list.
            if (is_first_item && before) child = false;

            return {child:child, before:before};
        },

        /*
            Given a mouse position and a hit element, work out the display
            position for the insertion preview.
        */
        positionLandingPreview: function(ev_xy, el_hit) {
            if (el_hit == this.curr_drag.el) return;

            var landing = this.determineMouseLandingCase(ev_xy, el_hit);

            // HACK: The positioning tweak constants here are all arbitrary.
            var el_hit_r  = Dom.getRegion(el_hit);
            var caret_pos = [
                landing.child  ? el_hit_r.left + ( this.getHandlePadding(el_hit) * 2 ) : el_hit_r.left,
                landing.before ? ( el_hit_r.top - 5 ) : ( el_hit_r.bottom - 4 )
            ];

            Dom.replaceClass('outliner_caret', 'outliner_hidden', 'outliner_shown');
            Dom.setXY('outliner_caret', caret_pos);
        },

        /*
            Given an element to move, a context element, and a landing
            condition, perform the landing.
        */
        performLanding: function(el, el_hit, landing, do_undo) {
            // Only one landing site is valid when dragging over original
            // item spot.
            if ( el_hit != el || (landing.child && landing.before) ) {

                var old_parent = el.parentNode;

                // Land as a child of the hit item's previous sibling.
                if ( landing.child &&  landing.before) {
                    var target_sibling = DOMUtils.previousSiblingByTagName(el_hit, 'li');
                    if (old_parent) old_parent.removeChild(el);
                    DOMUtils.appendListChild(target_sibling, el);
                    this.adjustHandleClasses(target_sibling, true);
                }

                // Land as the hit item's new previous sibling.
                if (!landing.child &&  landing.before) {
                    if (old_parent) old_parent.removeChild(el);
                    el_hit.parentNode.insertBefore(el, el_hit);
                }

                // Land as a child of the hit item.
                if ( landing.child && !landing.before) {
                    if (old_parent) old_parent.removeChild(el);
                    DOMUtils.appendListChild(el_hit, el);
                }

                // Land as the next sibling of the hit item.
                if (!landing.child && !landing.before) {
                    var target_sibling = DOMUtils.nextSiblingByTagName(el_hit, 'li');
                    if (target_sibling != el) {
                        if (old_parent) old_parent.removeChild(el);
                        el_hit.parentNode.insertBefore(el, target_sibling);
                    }
                }

                // Destroy the parent UL if it's empty of children.
                if (old_parent && old_parent.getElementsByTagName('li').length == 0) {
                    var parent_item = old_parent.parentNode;
                    // this.adjustHandleClasses(target_sibling, false);
                    parent_item.removeChild(old_parent);
                    this.adjustHandleClasses(parent_item);
                }
                old_parent = null;

                // Finally, adjust the handle classes on the context element.
                this.adjustHandleClasses(el_hit);

                // Autoscroll if need be, especially for keyboard access.
                var xy = Dom.getXY(el);
                var r  = Dom.getRegion(el);
                this.autoScroll(xy[0], xy[1], ( r.bottom - r.top ) + 80, (r.right - r.left) + 80);

                // Rescan the outline to reflect whatever changes were made.
                this.outlineChanged(do_undo)
                
                // Update the editor after this item moves, if it's being edited.
                if (this.manager.editor.isActiveEditing(el)) 
                    this.manager.edit(el, this);

            }
            return false;

        },

        /*
            Adjust the item handle classes according to presence of children.
        */
        adjustHandleClasses: function(el, is_parent) {
            var ul = this.findChildListContainer(el);
            if (is_parent || (is_parent!=false && ul)) {
                
                // HACK: Upon initial load, find items with compact="compact" and replace that with outliner classes.
                if (el.getAttribute('compact') == 'compact') {
                    Dom.addClass(el, 'outliner_item_collapsed');
                    Dom.addClass(ul, 'outliner_hidden');
                    el.setAttribute('compact', '');
                }

                Dom.removeClass(el, "outliner_item_leaf");
                if (!Dom.hasClass(el, "outliner_item_collapsed"))
                    Dom.addClass(el, "outliner_item_expanded");

            } else {

                Dom.removeClass(el, "outliner_item_expanded");
                Dom.removeClass(el, "outliner_item_collapsed");
                Dom.addClass(el, "outliner_item_leaf");

            }
        },

        /*
            Attempt to work out how wide the CSS handle padding is for an
            element, fallback to default constant.
        */
        getHandlePadding: function(el) {
            var handle_pad = Dom.getStyle(el, 'paddingLeft').replace(/px/,'');
            if (isNaN(handle_pad)) handle_pad = this.OUTLINER_HANDLE_WIDTH;
            return parseInt(handle_pad);
        },

        /*
            Toggle an outline item open or closed.
        */
        toggleItem: function(el, force_open) {
            var log = this.getLog("toggleItem");

            var ul = this.findChildListContainer(el);
            if (!ul) return false;

            if (force_open || (force_open!=false && Dom.hasClass(ul, 'outliner_hidden'))) {
                Dom.replaceClass(ul, 'outliner_hidden', 'outliner_shown');
                Dom.replaceClass(el, 'outliner_item_collapsed', 'outliner_item_expanded');
            } else {
                Dom.replaceClass(ul, 'outliner_shown', 'outliner_hidden');
                Dom.replaceClass(el, 'outliner_item_expanded', 'outliner_item_collapsed');
            }

            // Rescan the outline to reflect whatever changes were made.
            this.outlineChanged();
        },

        /*
         * Stolen from YAHOO.util.DD
         * @private
         */
        getScroll: function() {
            var t, l;
            if (document.documentElement && document.documentElement.scrollTop) {
                t = document.documentElement.scrollTop;
                l = document.documentElement.scrollLeft;
            } else if (document.body) {
                t = document.body.scrollTop;
                l = document.body.scrollLeft;
            }
            return { top: t, left: l };
        },

        /*
         * Stolen from YAHOO.util.DD
         * auto-scroll the window if the dragged object has been moved beyond the
         * visible window boundary.
         *
         * @param {int} x the drag element's x position
         * @param {int} y the drag element's y position
         * @param {int} h the height of the drag element
         * @param {int} w the width of the drag element
         * @private
         */
        autoScroll: function(x, y, h, w) {

            // The client height
            var clientH = Dom.getClientHeight();

            // The client width
            var clientW = Dom.getClientWidth();

            var s = this.getScroll();

            // The amt scrolled down
            var st = s.top;

            // The amt scrolled right
            var sl = s.left;

            // Location of the bottom of the element
            var bot = h + y;

            // Location of the right of the element
            var right = w + x;

            // The distance from the cursor to the bottom of the visible area,
            // adjusted so that we don't scroll if the cursor is beyond the
            // element drag constraints
            var toBot = (clientH + st - y);// - this.deltaY);

            // The distance from the cursor to the right of the visible area
            var toRight = (clientW + sl - x);// - this.deltaX);

            // this.logger.log( " x: " + x + " y: " + y + " h: " + h +
            // " clientH: " + clientH + " clientW: " + clientW +
            // " st: " + st + " sl: " + sl + " bot: " + bot +
            // " right: " + right + " toBot: " + toBot + " toRight: " + toRight);

            // How close to the edge the cursor must be before we scroll
            // var thresh = (document.all) ? 100 : 40;
            var thresh = 40;

            // How many pixels to scroll per autoscroll op.  This helps to reduce
            // clunky scrolling. IE is more sensitive about this ... it needs this
            // value to be higher.
            var scrAmt = (document.all) ? 80 : 30;

            // Scroll down if we are near the bottom of the visible page and the
            // obj extends below the crease
            if ( bot > clientH && toBot < thresh ) {
                window.scrollTo(sl, st + scrAmt);
            }

            // Scroll up if the window is scrolled down and the top of the object
            // goes above the top border
            if ( y < st && st > 0 && y - st < thresh ) {
                window.scrollTo(sl, st - scrAmt);
            }

            // Scroll right if the obj is beyond the right border and the cursor is
            // near the border.
            if ( right > clientW && toRight < thresh ) {
                window.scrollTo(sl + scrAmt, st);
            }

            // Scroll left if the window has been scrolled to the right and the obj
            // extends past the left border
            if ( x < sl && sl > 0 && x - sl < thresh ) {
                window.scrollTo(sl - scrAmt, st);
            }

        },

        EOF:null
    };
}();

/**
 * Floating editing field for outline items.
 */
XoxoOutliner.OutlineItemEditor = function(manager) { 
    this.init(manager); 
}
XoxoOutliner.OutlineItemEditor.prototype = function() {
    var Dom = YAHOO.util.Dom;
    var Evt = YAHOO.util.Event;

    return {
        _log_cache: {},

        init: function(manager) {
            this.manager = manager;

            // TODO: Figure out how to handle multi-line item editing.
            this.field = TEXTAREA( {'id':'outliner_item_editor', 'class':'outliner_item_editor'}, '');
            // var field = INPUT( {'type':'text', 'class':'itemeditor'}, null);
            document.body.insertBefore(this.field, document.body.firstChild);
            this.field_id = this.field.id;
            this.field.style.display = "none";

            var key_evt_name = (window.addEventListener) ? 'keypress' : 'keydown';
            Evt.on(this.field, key_evt_name, this.onKeyDispatch, this, true);
            Evt.on(this.field, 'unload',     this.destroy,       this, true);
            Evt.on(this.field, 'mouseup',    this.onMouseUp,     this, true);
            //Evt.on(this.field, 'mousedown',  this.onMouseDown,  this, true);
            //window.setTimeout(function() { editor.clear(); }, 20);
        },

        /*
            Get a logger configured for this object, customized for method.
        */
        getLog: function(cat) {
            if (!this._log_cache[cat])
                var field_id = this.field_id;
                this._log_cache[cat] = function(msg, lvl) {
                    YAHOO.log(msg, (lvl || "debug"), "OutlineItemEditor["+field_id+"]:"+cat);
                };
            return this._log_cache[cat];
        },
        log: function(msg, lvl) {
            YAHOO.log(msg, (lvl || "debug"), "OutlineItemEditor["+this.field_id+"]");
        },

        /*
            Let go of leaky elements and other crud.
        */
        destroy: function() {
            this.field = null;
            this.curr_el = null;
        },

        /*
            HACK: Relay a mousedown in the field up to the outliner itself.
        */
        onMouseDown: function(e) {
            return this.curr_outliner.onMouseDown(e);
        },

        /*
            HACK: Prevent breaking mouse-up side of outline click and sudden
            appearance of editor under the mouse by relaying a mouseup in the
            field up to the outliner itself to prev
        */
        onMouseUp: function(e) {
            if (this.curr_outliner.curr_drag)
                return this.curr_outliner.onMouseUp(e);
        },

        /*
            Escape: Cancel editing.
        */
        onKey_ESC: function(evt) {
            this.commit();
            return false;
        },

        /*
            Pass through backspace, left, and right cursor keys in editor field.
        */
        onKey_BS: function(evt) { 
            if (this.field.value.length == 0) {
                this.cancel();
                return this.curr_outliner.onKey_BS(evt, true);
            }
            return true; 
        },
        onKey_LEFT: function(e) { 
            return true; 
        },
        onKey_RIGHT: function(e) { 
            return true; 
        },

        /*
            Up, Down: Nothing yet
        // TODO: Figure out how to handle multi-line item editing.
        onKey_UP: function(e) {
            // Pass through.
            return true;
        },
        onKey_DOWN: function(e) {
            // Pass through.
            return true;
        },
        */

        /*
            Return: Commit the current item, insert and edit a new next
            sibling.
        */
        onKey_RETURN: function(evt) {
            this._insertNewItem({ before: false, child: false });
            return false;
        },

        /*
            Shift-Return: Commit the current item, insert and edit a new
            child node.
        */
        onKey_SHIFT_RETURN: function(evt) {
            this._insertNewItem({ before: false, child: true });
            return false;
        },

        /*
            Given a landing, create a new item, insert it at the proper landing
            with respect to current edited item, commit any changes, fixup the
            new item, and switch to editing it.  (Whew.)
        */
        _insertNewItem: function(landing) {
            var new_item = LI({}, DIV({},'\u00a0'));
            var outliner = this.curr_outliner;
            outliner.performLanding(new_item, this.curr_el, landing, false);
            this.commit(false);
            this.curr_outliner.adjustHandleClasses(new_item);
            this.curr_outliner.selectItem(new_item);
            this.edit(new_item, outliner, '');
            new_item = null;
        },

        /*
            Given an element and an outliner, position and ready the editor
            field.
        */
        edit: function(el, outliner, initial_content) {

            // Commit any changes made in a previous editor configuration.
            this.commit();

            // Change the current outliner and edited element appropriately.
            this.curr_outliner = outliner;
            this.curr_el = el;

            // Place & size the editor with appropriate wackiness.
            var fld  = this.field;
            var cn   = this.findContentNode();
            var cn_r = Dom.getRegion(cn);
            var el_r = Dom.getRegion(el);
            var vp_w = Dom.getViewportWidth();

            // TODO: Figure out how to handle multi-line item editing.
            fld.style.position = 'absolute';
            fld.style.left     = (cn_r.left  + 0 ) + "px";
            fld.style.top      = (cn_r.top   + 0 ) + "px";
            fld.style.width    = (vp_w  - cn_r.left - 10 ) + "px";
            fld.style.height   = (cn_r.bottom - cn_r.top - 0) + "px";
            fld.style.display  = "block";
            
            // Populate the editor field with element content.
            fld.value = (initial_content!=null) ? initial_content : this.getContent();

            // HACK: Tweak to prevent page jump with focus.
            window.setTimeout(function() { fld.focus(); fld=null; }, 20);
            cn = null;
        },

        /*
            Determine whether the editor is currently active.
        */
        isActive: function() {
            return (this.curr_el != null);
        },

        /*
            Determine whether the editor is currently active editing the given
            element.
        */
        isActiveEditing: function(el) {
            return (this.curr_el == el);
        },

        /*
            Remove the editor from display and deactivate it.
        */
        clear: function() {
            var fld = Dom.get(this.field_id);
            fld.style.display = 'none';
            this.curr_el = null;
            this.field.blur();
        },

        /*
            Cancel and deactivate editing without saving changes.
        */
        cancel: function() {
            this.clear();
        },

        /*
            Commit the editor field changes to the edited node content.
        */
        commit: function(do_undo) {
            if (this.curr_el) {
                var fld = this.field;
                var ele = this.curr_el;
                this.setContent(fld.value);
                this.curr_outliner.outlineChanged(do_undo);
            }
            this.clear();
        },

        /*
            Find the content node contained by an outline item element.
        */
        findContentNode: function() {
            if (!this.curr_el) return null;
            var ele = this.curr_el;
            for (var i=0, node; node=ele.childNodes[i]; i++)
                if (Node.ELEMENT_NODE==node.nodeType) return node;
            ele = null;
        },

        /*
            Harvest the content from a content node.
        */
        getContent: function() {
            var ele = this.curr_el;

            var content_node = this.findContentNode();
            // HACK: This is not good
            //return DBUtils.trim(scrapeText(content_node));
            if (!content_node) return '';
            var ct = DOMUtils.trim(content_node.innerHTML);
            if (ct == '&nbsp;') ct = '';
            return ct;
        },

        /*
            Replace the content in a content node.
        */
        setContent: function (content) {
            if (content == '') return;

            var content_node = this.findContentNode();
            // HACK: This is not good
            content_node.innerHTML = content;
        }

    };
}();

