// chalk.js -- JavaScript for web-based whiteboard
// Copyright (C) May 2007, Chouser <chouser@bluweb.com>

function setPos( elem, pos ) {
  elem.style.top  = pos[1] + 'px';
  elem.style.left = pos[0] + 'px';
}

function uniqueId( hash, id ) {
  while( ! id || hash[ id ] ) {
    id = Math.floor( Math.random() * 10000 );
  }
  return id;
}

function deepCopy( o ) {
  var copy;
  if( o instanceof Object ) {
    copy = o.constructor();
    for( var key in o ) {
      copy[ key ] = deepCopy( o[ key ] );
    }
  }
  else {
    copy = o;
  }
  return copy;
}

CDoc = function() {
  var thisCDoc = this;
  var nodehash = {};
  var linehash = {};
  var home = null;

  thisCDoc.load = function( opts ) {
    var i;
    for( i = 0; i < opts.nodes.length; ++i ) {
      thisCDoc.addNode( opts.nodes[ i ] );
    }
    for( i = 0; i < opts.lines.length; ++i ) {
      thisCDoc.addLine( opts.lines[ i ] );
    }
  }

  thisCDoc.findNodeAt = function( findPos ) {
    var cnode;
    for( var i in nodehash ) {
      cnode = nodehash[ i ];
      if( cnode.getPos()[ 0 ] == findPos[ 0 ] &&
          cnode.getPos()[ 1 ] == findPos[ 1 ] )
      {
        return cnode;
      }
    }
    return null;
  };

  thisCDoc.addNode = function( opts ) {
    opts.id = uniqueId( nodehash, opts.id || opts.str );
    var cnode = new CNode( opts );
    nodehash[ opts.id ] = cnode;
    return cnode;
  };

  thisCDoc.addLine = function( opts ) {
    opts.id = uniqueId( linehash, opts.id || opts.str );
    var cline = new CLine( opts );
    linehash[ opts.id ] = cline;
    return cline;
  };

  thisCDoc.nodeById = function( id ) {
    return nodehash[ id ];
  };

  thisCDoc.getOpts = function() {
    var id;
    var nodes = [];
    for( id in nodehash ) {
      nodes.push( nodehash[ id ].getOpts() );
    }
    var lines = [];
    for( id in linehash ) {
      lines.push( linehash[ id ].getOpts() );
    }
    return { home: home, nodes: nodes, lines: lines };
  };

  thisCDoc.setHome = function( newHome ) {
    home = [ newHome[0], newHome[1] ];
  };

  thisCDoc.merge = function( otherDocOpts, newHome ) {
    var i, node;

    // If the doc doesn't have a home, pick a default
    if( ! otherDocOpts.home ) {
      otherDocOpts.home = otherDocOpts.nodes[0].pos;
    }

    var delta = [
      newHome[0] - otherDocOpts.home[0],
      newHome[1] - otherDocOpts.home[1] ];
    var nodeIdRemap = {};
    var opts, oldid;
    for( i = 0; i < otherDocOpts.nodes.length; ++i ) {
      opts = deepCopy( otherDocOpts.nodes[ i ] );
      oldid = opts.id;
      opts.pos[ 0 ] += delta[ 0 ];
      opts.pos[ 1 ] += delta[ 1 ];
      // XXX avoid collisions?
      node = thisCDoc.addNode( opts );
      // Record the new and old id (even if they're the same), in case
      // an id conflict caused us to use a new id.  This we can get
      // the lines hooked up right.
      nodeIdRemap[ oldid ] = node.getId();
    }
    for( i = 0; i < otherDocOpts.lines.length; ++i ) {
      opts = deepCopy( otherDocOpts.lines[ i ] );
      opts.nodeids[ 0 ] = nodeIdRemap[ opts.nodeids[ 0 ] ] || opts.nodeids[0];
      opts.nodeids[ 1 ] = nodeIdRemap[ opts.nodeids[ 1 ] ] || opts.nodeids[1];
      thisCDoc.addLine( opts );
    }

    ccursor.selectHere( thisCDoc );
  };

  thisCDoc.yank = function( yanklist, cut ) {
    var yankhash = {};
    var i, id;
    var nodes = [];
    for( i = 0; i < yanklist.length; ++i ) {
      id = yanklist[ i ].getId();
      yankhash[ id ] = true;
      nodes.push( nodehash[ id ].getOpts() );
      if( cut ) {
        nodehash[ id ].remove();
        delete nodehash[ id ];
      }
    }
    var lines = [];
    var nodeids;
    for( id in linehash ) {
      nodeids = linehash[ id ].getOpts().nodeids;
      for( i = 0; i < nodeids.length ; ++i ) {
        if( yankhash[ nodeids[ i ] ] ) {
          lines.push( linehash[ id ].getOpts() );
          if( cut ) {
            linehash[ id ].remove();
            delete linehash[ id ];
          }
        }
      }
    }
    return { nodes: nodes, lines: lines };
  };
};
var maindoc = new CDoc();

CNode = function( opts ) {
  var thisCNode = this;
  var pos, el, text, id;

  function init() {
    el = document.createElement('a');
    el.className = 'cnode';
    el.appendChild( text = document.createTextNode( opts.str ) );
    YAHOO.util.Event.addListener( el, 'focus', relayEvent );
    YAHOO.util.Event.addListener( el, 'click', relayEvent );

    pos = [];
    thisCNode.setPos( opts.pos );
    document.body.appendChild( el );

    id = opts.id || opts.str;

    ccursor.tag( thisCNode );
  };

  this.getOpts = function() {
    return { id: id, pos: pos, str: text.nodeValue };
  };

  function relayEvent( e ) {
    ccursor.nodeEvent( thisCNode, e );
  };

  this.setPos = function( newPos ) {
    pos[ 0 ] = newPos[ 0 ];
    pos[ 1 ] = newPos[ 1 ];
    setPos( el, pos );
  };

  this.getPos = function() {
    return [ pos[0], pos[1] ];
  };

  this.getId = function() {
    return id;
  };

  this.getCenterPos = function() {
    // TODO: take into account padding and line width
    return [ pos[0] + (el.offsetWidth/2), pos[1] + (el.offsetHeight/2) ];
  };

  this.hideText = function() {
    el.style.display = 'none';
    return text.nodeValue;
  };

  this.showText = function( str ) {
    text.nodeValue = str;
    el.style.display = '';
  };

  this.removeClass = function( className ) {
    YAHOO.util.Dom.removeClass( el, className );
  };

  this.addClass = function( className ) {
    YAHOO.util.Dom.addClass( el, className );
  };

  this.remove = function() {
    el.parentNode.removeChild( el );
    relayEvent( { type: 'remove' } );
  };

  init();
}

CLine = function( opts ) {
  var thisCLine = this;
  var canvas, ctx, cnodes;

  function init() {
    canvas = document.createElement('canvas');
    canvas.className = 'cline';
    document.body.appendChild( canvas );

    if( opts.nodes ) {
      cnodes = opts.nodes;
    }
    else if( opts.nodeids ) {
      cnodes = [ maindoc.nodeById( opts.nodeids[0] ),
                 maindoc.nodeById( opts.nodeids[1] ) ];
    }

    thisCLine.link();
    id = opts.id;
  }

  thisCLine.link = function() {
    var pos = [ cnodes[0].getCenterPos(), cnodes[1].getCenterPos() ];

    var margin = 50;
    var min = [ Math.min( pos[0][0], pos[1][0] ) - margin,
                Math.min( pos[0][1], pos[1][1] ) - margin ];
    var max = [ Math.max( pos[0][0], pos[1][0] ) + margin,
                Math.max( pos[0][1], pos[1][1] ) + margin ];

    setPos( canvas, min );
    canvas.width  = max[0] - min[0];
    canvas.height = max[1] - min[1];

    ctx = canvas.getContext('2d');
    ctx.moveTo( pos[0][0] - min[0], pos[0][1] - min[1] );
    ctx.bezierCurveTo(
        pos[0][0] - min[0], (pos[1][1]*2 + pos[0][1]  )/3 - min[1],
        pos[1][0] - min[0], (pos[1][1]   + pos[0][1]*2)/3 - min[1],
        pos[1][0] - min[0], pos[1][1] - min[1] );
    ctx.stroke()
  };

  thisCLine.getOpts = function() {
    return { nodeids: [ cnodes[0].getId(), cnodes[1].getId() ] };
  };

  thisCLine.remove = function() {
    canvas.parentNode.removeChild( canvas );
  };

  init();
};

function js2post( o ) {
  var pairs = [];
  for( var key in o ) {
    if( o[key] !== undefined && o[key] !== null ) {
      pairs.push( escape(key) + '=' + escape(o[key]) );
    }
  }
  return pairs.join('&');
}

function send( sendObj, success ) {
  var url = /^([^#]*)/.exec(location.href)[1] + '_chalk.cgi';
  var callback = {
    success: function( o ) {
      var respObj = eval( o.responseText );
      if( respObj.constructor == Object && respObj.error ) {
        alert( respObj.error );
      }
      else {
        success( eval( o.responseText ) )
      }
    },
    failure: function( o ) { alert( o.status + ": " + o.statusText ); }
  };
  YAHOO.util.Connect.asyncRequest('POST', url, callback, js2post( sendObj ));
}

CCursor = function() {
  var thisCCursor = this;
  var mode, div, input, pos, crntnode, taglist, tagdivs, bind;
  var modeOverrideOp, command, recording, keydisplay, completeTimer;
  var dotfunc, demokeys, demofunc, inputfocus;
  var clipboards = {};

  // Everything that uses inputval (instead of input.value) is related
  // to a workaround for a bug seen in Firefox 2.0.0.1 where an escape
  // key pressed in the input box sometimes replaces the input's value
  // with some more-or-less random string, before the escape key event
  // is delivered to the keypress handler.
  var inputval;

  function init() {
    div = document.createElement('div');
    div.className = 'ccursor';
    div.innerHTML = '&#9654;';
    document.body.appendChild( div );

    input = document.createElement('input');
    input.className = 'cinput';
    input.style.display = 'none';
    document.body.appendChild( input );
    input.onchange = function() { inputval = input.value; };

    ccpromptbox = document.createElement('table');
    ccpromptbox.innerHTML = '<tr><td class="ccpromptpre">&#9654;</td>' +
        '<td><input class="ccprompt" /></td></tr>';
    ccpromptbox.className = 'ccpromptbox';
    ccpromptbox.style.display = 'none';
    ccprompt = ccpromptbox.getElementsByTagName('input')[0];
    document.body.appendChild( ccpromptbox );

    keydisplay = document.createElement('div');
    keydisplay.className = 'keydisplay';
    keydisplay.style.display = 'none';
    document.body.appendChild( keydisplay );

    demodisplay = document.createElement('div');
    demodisplay.className = 'demodisplay';
    demodisplay.style.display = 'none';
    document.body.appendChild( demodisplay );

    var j;
    tagdivs = [];
    taglist = [];
    for( var i = 0; i < 10; ++i ) {
      j = document.createElement('div');
      j.className = 'ctag';
      j.style.display = 'none';
      j.innerHTML = (i + 1) % 10;
      document.body.appendChild( j );
      tagdivs.push( j );
    }

    pos = [
      Math.floor( YAHOO.util.Dom.getViewportWidth()  * 0.5 / 100 ) * 100,
      Math.floor( YAHOO.util.Dom.getViewportHeight() * 0.4 /  40 ) * 40 ];
    setPos( div, pos );
    maindoc.setHome( pos );

    recording = false;

    thisCCursor.setBind({
      command: {
          'j': { cursor: function(){ move(  0,  1 ); } },
          'k': { cursor: function(){ move(  0, -1 ); } },
          'h': { cursor: function(){ move( -1,  0 ); } },
          'l': { cursor: function(){ move(  1,  0 ); } },
          'i': { mode: function(){ setMode('insert'); } },
          ':': { mode: function(){ setMode('prompt'); } },
          'm': { modeOverride: { cursor: function( keystr ) {
            thisCCursor.tag( crntnode, keystr2tagNum( keystr ) );
          }}},
          's': { modeOverride: { doc: function( keystr ) {
            maindoc.addLine({ nodes: [keystr2cnode(keystr), crntnode] });
          }}},
          "'": { modeOverride: { cursor: function( keystr ) {
            selectNode( keystr2cnode(keystr) );
          }}},
          "x": { doc: function(){
            clipboards['z'] = maindoc.yank( [ crntnode ], true );
            clipboards['z'].home = [ pos[0], pos[1] ];
          }},
          "y": { cursor: function(){
            clipboards['z'] = maindoc.yank( [ crntnode ] );
            clipboards['z'].home = [ pos[0], pos[1] ];
          }},
          "p": { doc: function(){ maindoc.merge( clipboards['z'], pos); }},
          "q": function(){
            if( recording ) {
              return { cursor: function() {
                clipboards[ recording ].pop(); // drop this "q"
                recording = null;
                alert('done recording');
              }};
            }
            else {
              return { modeOverride: { cursor: function( keystr ) {
                alert('begin recording ' + keystr);
                recording = keystr;
                clipboards[ recording ] = [];
              }}};
            }
          },
          "@": { modeOverride: { doc: function( recstr ) {
            var macro = clipboards[ recstr ];
            var keystr, op;
            for( var i = 0; i < macro.length; ++i ) {
              keystr = macro[ i ];
              op = getOpForKey( keystr );
              if( op ) {
                if( op.modeOverride ) { modeOverrideOp = op.modeOverride; }
                if( op.mode )         { op.mode( keystr ); }
                if( op.cursor )       { op.cursor( keystr ); }
                if( op.doc )          { op.doc( keystr ); }
              }
              else if( inputfocus ) {
                inputfocus.value += keystr2char( keystr );
                inputval = inputfocus.value;
              }
            }
          }}},
          '.': { cursor: function() { dotfunc(); } },
          '/': 'allowDefault'
      },
      insert: {
          '<CR>':  { mode: doInsert },
          '<ESC>': { mode: doInsert }
      },
      prompt: {
          '<CR>':  { mode: function() {
            setMode( 'command' );
            thisCCursor.doPrompt( ccprompt.value );
          } },
          '<ESC>': { mode: function(){ setMode('command'); } }
      }
    });

    mode = 'command';

    YAHOO.util.Event.addListener( document, 'click', bodyClick );
  }

  function keystr2tagNum( keystr ) {
    return keystr < '0' ? 9 : keystr.charCodeAt( 0 ) - 49;
  }

  function keystr2cnode( keystr ) {
    return taglist[ keystr2tagNum( keystr ) ];
  }

  function keystr2char( keystr ) {
    return ({
        '<SPACE>': ' '
    })[ keystr ] || keystr;
  }

  function event2keystring( e ) {
    var prefix = '';
    if( e.ctrlKey ) { prefix += 'C-'; }
    if( e.altKey  ) { prefix += 'A-'; }
    if( e.metaKey ) { prefix += 'M-'; }

    if( e.keyCode == 0 ) {
      var name = String.fromCharCode( e.charCode );
      if( prefix ) {
        return '<' + prefix + name + '>';
      }
      else {
        return name;
      }
    }
    else {
      var name = ({
        13: 'CR',
        27: 'ESC'
      })[ e.keyCode ];
      return '<' + prefix + name + '>';
    }
  }

  function splitkeys( str ) {
    var keyarray = [];
    var parsekeys = /\\.|<(?:\w|-)*>|[^\\<>]/g;
    var matcharray;
    while( (matcharray = parsekeys.exec( str )) ) {
      keyarray.push( matcharray[ 0 ] );
    }
    return keyarray;
  }

  function selectNode( cnode ) {
    if( crntnode ) { crntnode.removeClass( 'current' ); }
    crntnode = cnode;
    if( crntnode ) {
      crntnode.addClass( 'current' );
      pos = crntnode.getPos();
      setPos( div, pos );
    }
  }

  thisCCursor.selectHere = function( doc ) {
    selectNode( (doc || maindoc).findNodeAt( pos ) );
  };

  function bodyClick( e ) {
    setMode( 'command' );
    pos[0] = Math.floor( e.clientX / 100 ) * 100;
    pos[1] = Math.floor( e.clientY /  40 ) *  40;
    setPos( div, pos );
    thisCCursor.selectHere();
  }

  thisCCursor.setBind = function( obj ) {
    bind = bind || { command: {}, insert: {}, prompt: {} };
    for( var mode in obj ) {
      for( var keystr in obj[ mode ] ) {
        // XXX should canonicalize keystr in dest
        bind[ mode ][ keystr ] = obj[ mode ][ keystr ];
      }
    }
  };

  function move( xcd, ycd ) {
    pos[0] += xcd * 100;
    pos[1] += ycd * 40;
    setPos( div, pos );
    thisCCursor.selectHere();
  }

  function setMode( newMode, func ) {
    if( newMode != mode ) {
      if( mode == 'insert' ) { input.blur()    };
      if( mode == 'prompt' ) { ccprompt.blur() };

      mode = newMode;
      div.style.display         = (mode == 'command') ? '' : 'none';
      input.style.display       = (mode == 'insert')  ? '' : 'none';
      ccpromptbox.style.display = (mode == 'prompt')  ? '' : 'none';
      inputfocus = null;
      if( mode == 'insert' ) {
        setPos( input, pos );
        inputval = input.value = crntnode ? crntnode.hideText() : '';
        input.focus();
        inputfocus = input;
      }
      else if( mode == 'prompt' ) {
        ccprompt.value = ':';
        ccprompt.focus();
        inputfocus = ccprompt;
      }
    }
  }

  function doInsert() {
    if( crntnode ) {
      // edit existing node
      crntnode.showText( inputval );
    }
    else {
      // create new node
      selectNode( maindoc.addNode({ str: inputval, pos: pos }) );
    }
    setMode( 'command' );
  }

  function clearDemo() {
    demodisplay.innerHTML = '';
    demodisplay.style.display = 'none';
    YAHOO.util.Dom.removeClass( demodisplay, 'keycomplete' );
  }

  function continueDemo() {
    var delay = 1000;
    if( demofunc ) {
      demofunc();
      demofunc = null;
      clearDemo();
    }
    else {
      var keystr = demokeys.shift();
      var op = getOpForKey( keystr );
      if( op ) {
        if( demodisplay.innerHTML == '' ) {
          demodisplay.style.display = '';
        }
        demodisplay.innerHTML += keystr;
        if( op.modeOverride ) {
          modeOverrideOp = op.modeOverride;
        }
        else {
          YAHOO.util.Dom.addClass( demodisplay, 'keycomplete' );
          if( op.mode )   { demofunc = function() { op.mode( keystr ); } }
          if( op.cursor ) { demofunc = function() { op.cursor( keystr ); }; }
          if( op.doc )    { 
            demofunc = function() {
              dotfunc = function() { op.doc( keystr ); };
              op.doc( keystr );
            };
          }
        }
      }
      else {
        delay = 0;
        demofunc = function() {
          inputfocus.value += keystr2char( keystr );
          inputval = inputfocus.value;
        };
      }
    }

    if( demokeys.length > 0 || demofunc ) {
      setTimeout( continueDemo, delay );
    }
    else {
      clearDemo();
    }
  }

  var promptCommands = {
    'w': function( path, mod ) {
      var data = maindoc.getOpts().toSource();
      send( { cmd: 'write', path: path, mod: mod, data: data }, function(o) {
        maindoc.setPath( o );
      });
    },

    'e': function( path, mod ) {
      send( { cmd: 'read', path: path, mod: mod }, function(o) {
        maindoc.load( o );
      });
    },

    'r': function( path, mod ) {
      send( { cmd: 'read', path: path, mod: mod }, function(o) {
        maindoc.merge( o, pos );
      });
    },

    'd': function( path, mod ) {
      demofunc = null;
      demokeys = splitkeys( path );
      demodisplay.style.display = '';
      continueDemo();
    }
  };

  thisCCursor.doPrompt = function( cmd ) {
    var m;
    if( m = /^:(\w)([!]?)(?: +(\S*))?$/.exec( cmd ) ) {
      promptCommands[ m[1] ].call( thisCCursor, m[3], m[2] );
    }
    else if( m = /^:(.*)/.exec( cmd ) ) {
      eval( m[1] );
    }
  };

  thisCCursor.tag = function( cnode, tagnum ) {
    if( cnode ) {
      var i;
      if( tagnum === undefined ) {
        for( i = 0; i < tagdivs.length; ++i ) {
          if( ! taglist[ i ] ) {
            tagnum = i;
            break;
          }
        }
      }

      if( tagnum !== undefined ) {
        thisCCursor.untag( cnode );
        taglist[ tagnum ] = cnode;
        tagdivs[ tagnum ].style.display = '';
        setPos( tagdivs[ tagnum ], taglist[ tagnum ].getPos() );
      }
    }
  };

  thisCCursor.untag = function( cnode ) {
    for( var i = 0; i < tagdivs.length; ++i ) {
      if( taglist[ i ] == cnode ) {
        taglist[ i ] = null;
        tagdivs[ i ].style.display = 'none';
      }
    }
  };

  function getOpForKey( keystr ) {
    if( recording ) {
      clipboards[ recording ].push( keystr );
    }

    var op;
    if( modeOverrideOp ) {
      op = modeOverrideOp;
      modeOverrideOp = null;
    }
    else {
      op = bind[ mode ][ keystr ];
    }

    if( typeof op == 'function' ) {
      op = op();
    }
    return op;
  };

  function clearKeyDisplay() {
    clearTimeout( completeTimer );
    completeTimer = null;
    keydisplay.innerHTML = '';
    keydisplay.style.display = 'none';
    YAHOO.util.Dom.removeClass( keydisplay, 'keycomplete' );
  }

  function keyPress( e ) {
    var keystr = event2keystring( e );
    var op = getOpForKey( keystr );

    if( ! op && ( e.ctrlKey || e.altKey || e.metaKey ) ) {
      return 'allowDefault';
    }

    if( op ) {
      if( op != 'allowDefault' ) {
        if( completeTimer ) {
          clearKeyDisplay();
        }
        keydisplay.innerHTML += keystr;
        keydisplay.style.display = '';

        if( op.modeOverride ) {
          modeOverrideOp = op.modeOverride;
        }
        else {
          YAHOO.util.Dom.addClass( keydisplay, 'keycomplete' );
          completeTimer = setTimeout( clearKeyDisplay, 500 );
          if( op.mode )   { op.mode( keystr ); }
          if( op.cursor ) { op.cursor( keystr ); }
          if( op.doc )    { 
            dotfunc = function() { op.doc( keystr ); };
            op.doc( keystr );
          }
        }

        if( e.preventDefault ) { e.preventDefault(); }
        return false;
      }
    }
    else {

      // no keybinding found.
      if( mode == 'command' ) {
        if( e.preventDefault ) { e.preventDefault(); }
        return false;
      }
      else {
        setTimeout( function() { inputval = input.value }, 0 );
      }
    }
  }

  this.listenForKeys = function() {
    YAHOO.util.Event.addListener( document, 'keypress', keyPress);
  }

  this.nodeEvent = function( cnode, e ) {
    if( e.type == 'remove' ) {
      thisCCursor.untag( cnode );
    }
    else {
      selectNode( cnode );
    }
  };

  init();
};

var ccursor;
YAHOO.util.Event.onAvailable( 'body', function() {
  ccursor = new CCursor();
  ccursor.listenForKeys();
  if( document.location.hash ) {
    m = /^#(:.*)/.exec( document.location.hash );
    if( m ) {
      ccursor.doPrompt( m[1] );
    }
    else {
      m = /^#([^\/]*)\/(.*)/.exec( document.location.hash );
      if( m ) {
        send( { cmd: 'read', owner: m[1], path: m[2] }, function(o) {
          maindoc.load( o );
        });
      }
    }
  }
} );


