11
\$\begingroup\$

After some free ASCII flowchart drawer started charging money, I decided to write my own. Salient features are that you can draw a box (mouse down, mouse move, mouse up), then ctrlB will draw a box. Copy/Paste, Undo, Redo, click anywhere and type are all working features. I pasted the main code here, and it relies on two other minor files to provide a cursor and box object, but this code should still be very reviewable.

I did run the code through JSHint and made a judgement call on the few remaining ickies. This is my first project with canvas that's not just a prototype, so any insights there are welcome. Finally, this will become a Chrome extension, so I only care about this working on Chrome. Except when I don't of course (which).

/* Unidraw, because we can*/
//Documentation:
//  http://en.wikipedia.org/wiki/Box-drawing_character
//Competition:
//  http://www.asciidraw.com/#Draw 
//  http://asciiflow.com/

(function IIFE(){
"use strict";

var canvas,
    context,
    clipboard;

var model = (function()
{
  //Privates
  var cells = [],
      tabSize = 4;
  //Exposed
  var cursor = new Cursor();
  function write( x, y, s )
  {
    //Make sure that we have an array for y
    //Always assume overwrite mode
    var originalX = x;
    cells[y] = cells[y] || [];
    for( var i = 0; i < s.length ; i++)
    {
      var c = s[i];
      if( c.charCodeAt(0) > 31 )
      {
        cells[y][x++] = s[i];
      }
      else if ( c == "\n" )
      {
        y++;
        cells[y] = cells[y] || [];
        x = originalX;
      }
      else if ( c == '\t' ) 
      {
        x += tabSize;
      }
    }
    return new Cursor( x, y );
  }
  function setCell( cursor , c )
  {
    return write( cursor.x , cursor.y , c );
  }
  function getCell( cursor )
  {
    return cells[cursor.y] ? cells[cursor.y][cursor.x] || " " : " ";
  }
  function stringify()
  {
    var s = '', x, y;
    for( y = 0 ; y < cells.length ; y++ )
    {
      if( cells[y] )
        for( x = 0 ; x < cells[y].length ; x++ )
          s = s + ( cells[y][x] || " " );
      s = s + '\n';
    }
    return s?s:" ";
  }
  function backspace()
  { //Move everything one character to the left of the cursor
    if( cells[ model.cursor.y ] )
      cells[ model.cursor.y ].splice( model.cursor.x-1 , 1 );
    model.cursor.recede();    
  }
  function addVersion( key )
  { //Called internally. add a version to a version array (found with `key`)
    var json = localStorage[key];
    var versions = json ? JSON.parse( json ) : [];
    versions.push( stringify() );
    localStorage[key] = JSON.stringify( versions );    
  }
  function getVersion( key )
  { //Called internally, get a version (and remove it) from a version array
    var json = localStorage[key];
    var versions = json ? JSON.parse( json ) : [];
    var version = versions.pop();
    localStorage[key] = JSON.stringify( versions );      
    return version;
  }
  function storeVersion()
  { //Called from controller, removes all redo versions
    addVersion( 'undo' , stringify()  );
    localStorage.removeItem( 'redo' );
  } 
  function restoreVersion()
  { //Called from controller, adds a redo version
    var version = getVersion( 'undo' );
    if(version){
      addVersion( 'redo' );
      cells = [];
      write( 0 , 0 , version );
    }
  }
  function redo()
  { //Called from controller, puts version back on to undo
    var version = getVersion( 'redo' );
    if(version){
      addVersion( 'undo' );
      cells = [];
      write( 0 , 0 , version );
    }    
  }
  function isLineCharacter(  cursor, dx , dy , returnValue )
  {
    cursor = { x: cursor.x + dx , y: cursor.y + dy };
    return ~'╔═╦╗║╠╬╣╚╩╝><'.indexOf( getCell( cursor ) ) ? returnValue : 0;          
  }  

  //Modulify
  return {
    write: write,
    stringify: stringify,
    setCell: setCell,
    getCell: getCell,
    cursor: cursor,
    backspace: backspace,
    storeVersion: storeVersion,
    restoreVersion: restoreVersion,
    redo: redo,
    isLineCharacter: isLineCharacter
  };
}());

var ui = (function()
{
  //Privates
  var fontSize = 15,
      breatheDuration = 5 * 1000, //5 seconds
      lightGrey = 211,
      black = 0,
      greyRange = lightGrey - black,
      p = 20, //Padding..
      magicalMultiplier = 0.8, //Dont ask
      w,  //Width
      h,  //Height
      fh, //fontHeight
      fw, //fontWidth
      vo, //Vertical offset for writing
      ho, //Horizontal offset for writing
      metrics,
      box;

  //Exposed    
  function breathe()
  { //Set the `caret` in a grey shade that follows a breathing cycle
    var rightNow = new Date(),
        position = rightNow % breatheDuration,
        radians = position / breatheDuration * Math.PI,
        sine = Math.sin( radians ),
        shade = Math.floor( lightGrey - greyRange/2 + sine * greyRange / 2 ),
        cx = model.cursor.x,
        cy = model.cursor.y;

    context.strokeStyle = 'rgb(' + shade + ',' + shade + ',' + shade + ')';             
    context.lineWidth = 0.5;
    context.beginPath();

    context.moveTo(cx*fw + p, cy*fh + p);
    context.lineTo(cx*fw + p + fw, cy*fh + p);
    context.lineTo(cx*fw + p + fw, cy*fh + p + fh);
    context.lineTo(cx*fw + p , cy*fh + p + fh);
    context.lineTo(cx*fw + p, cy*fh + p);
    context.stroke();
  }
  function drawBox()
  {
    context.strokeStyle = 'black';             
    context.lineWidth = 0.5;
    context.beginPath();

    context.moveTo(box.from.x   *fw + p, box.from.y   *fh + p);  //Top left
    context.lineTo((box.to.x+1) *fw + p, box.from.y   *fh + p);  //Top Right
    context.lineTo((box.to.x+1) *fw + p, (box.to.y+1) *fh + p);  //Bottom Right
    context.lineTo(box.from.x   *fw + p, (box.to.y+1) *fh + p);  //Bottom Left
    context.lineTo(box.from.x   *fw + p, box.from.y   *fh + p);  //Top Left
    context.stroke();        
  }
  function setBox( cell1 , cell2 )
  {
    box = new Box( cell1 , cell2 );
  }
  function clearBox()
  {
    box = undefined;
  }  
  function getBox()
  {
    return box;
  }
  function adapt()
  { //Adapt the UI to the current size of the body
    //Clearly, the UI maintains it's own model
    w = canvas.width  = document.body.clientWidth;
    h = canvas.height = window.innerHeight;
    document.documentElement.style.overflow = 'hidden';
    context.font = fontSize + (~navigator.userAgent.indexOf('Mac') ? "px Consolas" : "px Monospace"); //EVIL Mac Fix
    metrics = context.measureText('A');
    fh = fontSize+1;
    fw = metrics.width;
    vo = p+fh*magicalMultiplier;
    ho = p;    
    drawGrid();    
  }
  function drawGrid()
  {
    context.clearRect(0, 0, canvas.width, canvas.height);
    for (var x = 0; x < w; x += fw) 
    {
      context.moveTo(x + p, 0 + p);
      context.lineTo(x + p, h );
    }

    for (var y = 0; y < h; y += fh) 
    {
      context.moveTo(0 + p, y + p);
      context.lineTo(w , y + p);
    }
    context.lineWidth = 0.1;
    context.strokeStyle = "lightgrey";
    context.stroke();    

    context.strokeStyle = "black";
    context.fillStyle = "black";    

    var string = model.stringify();
    if( string ){
      var strings = string.split("\n");
      for( var row = 0 ; row < strings.length ; row++ )
        for( var col = 0 ; col < strings[row].length ; col++ )
          context.fillText( strings[row][col] , ho  + fw * col  , vo + fh * row );  
    }

    if( box )
      drawBox( box.from , box.to );
  }
  function translate( cursor )
  { //Translate screen coordinates to cell coordinates
    var x = Math.floor((cursor.x - p ) / fw ),
        y = Math.floor((cursor.y - p ) / fh );
    //Cheat on boundaries
    x = x < 0 ? 0 : x;
    y = y < 0 ? 0 : y; 
    //Return a new cell cursor object
    return new Cursor(x,y);
  }

  //Modulify
  return {
    breathe : breathe,
    drawGrid : drawGrid,
    adapt: adapt,
    translate: translate,
    setBox: setBox,
    clearBox: clearBox,
    getBox: getBox
  };
}());

var controller = (function()
{  
  var BACKSPACE = 8,
      TAB = 9,
      ARROW_LEFT = 37,
      ARROW_UP = 38,
      ARROW_RIGHT = 39,
      ARROW_DOWN = 40,
      DELETE = 46,
      KEY_B = 66,
      KEY_C = 67,
      KEY_Y = 89,
      KEY_Z = 90;  

  var startingCell,
      currentCell;

  function normalizeEvent(e)
  { //Normalize which for key events, inspiration:SO
    if ( e.which === null && (e.charCode !== null || e.keyCode !== null) ) {
      e.which = e.charCode !== null ? e.charCode : e.keyCode;
    }    
  }

  function onContentLoaded()
  { //Could have been called onInit
    //Set the 3 globals
    canvas  = document.getElementById("canvas");
    context = canvas.getContext ("2d");
    clipboard = document.getElementById('clipboard');
    //Occupy full body & draw the initial UI
    ui.adapt();
    //Set up listeners
    window.addEventListener( "resize", ui.adapt );
    canvas.addEventListener( "mouseover", onMouseOver );
    canvas.addEventListener( "mousemove", onMouseOver );
    canvas.addEventListener( "mousedown", onMouseDown );
    canvas.addEventListener( "mouseup", onMouseUp );
    canvas.addEventListener( "click", onClick );
    document.addEventListener( "keypress", onKeyPress );
    document.addEventListener( "keydown", onKeyDown );
    document.addEventListener( "paste", onPaste );
    //Make the cursor breathe
    setInterval( ui.breathe , 1000/12 ); // 12 frames per second
  } 
  function onPaste(e)
  { //Determine where to paste, paste, determine & set new cursor location, redraw everything
    var cursor = model.cursor;
    model.cursor = model.write(  cursor.x , cursor.y ,  e.clipboardData.getData('text/plain') );
    ui.drawGrid();
  }
  function onMouseDown(e)
  {
    //Remember where we start
    startingCell = ui.translate( e );
    //Clear any old boxes
    ui.clearBox();
    //Force the UI in onMouseOver to draw the new cursor without a mouse up
    currentCell = { x : -1 , y : -1 };
    onMouseOver(e);
  }
  function onMouseUp()
  {
    ui.setBox( startingCell , currentCell );
    currentCell = startingCell = undefined;
  }
  function onMouseOver(e)
  { //Are we dragging?, which cell are we on, update if we are in a different cell, and draw
    if(!startingCell)
      return;
    var cell = ui.translate( e );
    if( cell.x != currentCell.x || cell.y != currentCell.y )
    {
      currentCell = cell;
      model.cursor =  cell;
      ui.setBox( startingCell , currentCell );
      ui.drawGrid();
    }
  }
  function onClick(e)
  { //Move the cursor to where the user clicked
    model.cursor = ui.translate( e );
    ui.drawGrid();
  }
  function onKeyPress(e)
  { //console.log( e , String.fromCharCode( e.charCode || 32 ) );
    if( e.ctrlKey  )
      return;
    model.storeVersion();
    model.setCell( model.cursor, String.fromCharCode( e.charCode || 32 ) );
    ui.clearBox();
    model.cursor.advance();
    ui.drawGrid();
  }
  function onKeyDown(e)
  { //console.log( e , String.fromCharCode( e.charCode || 32 ) );
    normalizeEvent(e);
    var box = ui.getBox();

    if( e.which == BACKSPACE )
    {
      model.backspace();
      e.preventDefault();
    } 
    else if( e.which == TAB )
    {
      model.cursor =  model.setCell( model.cursor , '\t' );
      e.preventDefault();
    }       
    else if( e.which == ARROW_LEFT ){
      model.cursor.recede();
    }    
    else if( e.which == ARROW_RIGHT ){
      model.cursor.advance();
    }
    else if( e.which == ARROW_UP ){
      model.cursor.up();     
    }
    else if( e.which == ARROW_DOWN ){
      model.cursor.down();     
    }      
    else if( e.keyIdentifier == 'Home' && e.ctrlKey ){
      model.cursor = new Cursor( 0, 0 );
    }
    else if( e.keyIdentifier == 'Home' )
    { //Move to complete left unless already there, in that case go top left
      model.cursor.x ? model.cursor.x = 0 : model.cursor.y = 0;
    }
    else if( e.which == KEY_C && e.ctrlKey )
    { //Copy a box or a whole character
      if( box )
      {
        var lines = [];
        box.eachRow( function(y){ lines[ y - box.from.y ] = ''; } );
        box.each( function(cursor){ lines[ cursor.y - box.from.y ] += model.getCell(cursor); } );
        var line = lines.join("\n");
        clipboard.value = line;
      } 
      else
      {
        clipboard.value = model.getCell( ui.getCursor() ) || " ";  
      }
      clipboard.focus();
      clipboard.select();
    }   
    else if( e.which == KEY_B && e.ctrlKey )
    {
      /* Styles:
         ╔═╦═╗  ⇓ 
         ║ ║ ║  ☺☺
         ╠═╬═╣ 
         ╚═╩═╝ */
      var onLeft   = 1; //Bitflag 1
      var onRight  = 2; //Bitflag 2
      var onTop    = 4; //Bitflag 3
      var onBottom = 8; //Bitflag 4
      var lineRules = {};
      lineRules[onLeft+onRight] = '═';
      lineRules[onTop+onBottom] = '║';
      lineRules[onTop+onLeft] = '╝';
      lineRules[onTop+onRight] = '╚';
      lineRules[onBottom+onLeft] = '╗';
      lineRules[onBottom+onRight] = '╔';        
      lineRules[onLeft+onRight+onTop+onBottom] = '╬';
      lineRules[onLeft+onRight+onTop] = '╩';
      lineRules[onLeft+onRight+onBottom] = '╦';
      lineRules[onTop+onBottom+onLeft] = '╣';
      lineRules[onTop+onBottom+onRight] = '╠';

      if( box )
      {
        model.storeVersion();
        //Show intent
        box.eachRow( function(y){ model.write( box.from.x, y, '║' ); model.write( box.to.x, y , '║' ); } );
        box.eachColumn( function(x){ model.write( x, box.from.y, '═' ); model.write( x, box.to.y , '═' ); } );
        //Line up
        box.each(  function lineUp( cursor )
        {
          if( !model.isLineCharacter( cursor , 0 , 0 , true ) )
            return;

          var neighbourBitFlag = 
             model.isLineCharacter( cursor , -1 , +0 , onLeft ) +
             model.isLineCharacter( cursor , +1 , +0 , onRight ) +
             model.isLineCharacter( cursor , +0 , +1 , onBottom ) +
             model.isLineCharacter( cursor , +0 , -1 , onTop );

            if( lineRules[neighbourBitFlag] )
              model.setCell( cursor , lineRules[neighbourBitFlag] );
          });
        } 
      }      
      else if ( e.which == DELETE )
      {
        if( box ){
          box.each( function(cursor){ model.setCell( cursor, " " ); } );
        }
      }
      else if ( e.which == KEY_Z && e.ctrlKey )
      { //Undo
        model.restoreVersion();
      }      
      else if ( e.which == KEY_Y && e.ctrlKey )
      { //Undo
        model.redo();
      }            
      //Clear the selection box after a key press (Control does not count)
      if( e.keyIdentifier != "Control" && box )
      {
        ui.clearBox();
      }
      //Draw the grid in all cases
      ui.drawGrid();
  }
  return {
    onContentLoaded: onContentLoaded,
  };
}());

//Engage!
document.addEventListener( "DOMContentLoaded", controller.onContentLoaded, false );

})();

A plunker can be found here.

\$\endgroup\$
  • \$\begingroup\$ Curlies on own line is a valid option on jsbeautifier.org, try it, you might like. I will look into mac ctrl keys, completely forgot about that. \$\endgroup\$ – konijn Jun 27 '14 at 21:56
  • 2
    \$\begingroup\$ I demand an explanation of magicalMultiplier! \$\endgroup\$ – 200_success Jun 28 '14 at 14:36
  • \$\begingroup\$ @200_success Ha! I second that demand. Didn't notice that one :) \$\endgroup\$ – Flambino Jun 28 '14 at 16:11
  • \$\begingroup\$ I did mention don't ask ;) The ascii symbols were off, and the magical constant fixes it. Even with different font sizes, not sure why that is possible. \$\endgroup\$ – konijn Jun 29 '14 at 13:52
  • \$\begingroup\$ Aw, you accepted my answer. I mean, thank you, of course, but I was hoping a more in-depth review would come along :/ I unfortunately just didn't have the time to do it justice - it's a very nice app! \$\endgroup\$ – Flambino Jul 10 '14 at 16:22
5
\$\begingroup\$

I like it! I've got some quibbles about the style, but that's personal opinion, and the code works, so I won't go into that. I haven't gone through the code line-by-line (there's a lot!), but I've tried looking at the overall structure.

If I were to suggest something, it might be a more declarative way to handle events, and keyboard event in particular. It's a minor thing, but the sort of thing I find more readable/direct.

You've got a lot of functions that are simply named for the event they handle, but then you have to repeat that name when attaching them to events. I'd consider defining them as properties on an object, and then loop through them, e.g.

var canvasEvents = {
  mouseover: function () { ... }
  mousemove: function () { ... }
  mousedown: function () { ... }
  mouseup: function () { ... }
  click: function () { ... }
};

for(var event in canvasEvents) {
  canvas.addEventListener(event, canvasEvents[event]);
}

Again, it's a minor thing, but defining the event handlers in one place and have them automatically attached would keep things nicely contained, I think.

And you could do a similar thing sort of thing for keyboard events, to avoid the large else if... else if... structure. For instance,

// Not the complete list - just a sampling
// Order still matters of course, so ctrl+Home gets matches before Home
var keyCommands = [
  {
    mask: { which: BACKSPACE },
    preventDefault: true,
    handler: model.backspace
  },
  {
    mask: { which: ARROW_UP },
    handler: model.cursor.up
  },
  {
    mask: { keyIdentifier: 'Home', ctrlKey: true },
    handler: function () { model.cursor = new Cursor( 0, 0 ) }
  },
  {
    mask: { keyIdentifier: 'Home' },
    handler: function () { model.cursor.x ? model.cursor.x = 0 : model.cursor.y = 0 }
  },
  {
    mask: { which: KEY_C, ctrlKey: true },
    handler: function () { /*... etc ...*/ }
  },
  ....
];

// ....

function handleKeyCombo(event) {
  normalizeEvent(event);

  function matchMask(mask) {
    for( var property in mask ) {
      if(event[property] != mask[property]) { // maybe use a strict comparison; your call
        return false;
      }
    }
    return true;
  }

  for( var i = 0, l = keyCommands.length ; i < l ; i++ ) {
    var command = keyCommands[i];
    if( matchMask(command.mask) ) {
      command.handler(event); // or use call/apply if necessary
      if(command.preventDefault) {
        event.preventDefault();
      }
      break;
    }
  }
}

(note that you'll have to figure out a way to pass the local var box to the handlers, now that they're defined in a different scope)

Aside: It might be a good use-case for the Map object, if available - masks as keys, handlers as values?

Alternatively, you could also make an addKeyboardShortcutListener function that works similar to addEventListener, but accepts the key-combo mask as well as a handler.

Basically, you can go more or less in-depth with this, but it'd make it (hopefully) easier to set up keyboard-handling, and (with some modification) use different key combos depending on platform.

For instance, on the Mac, cmd is used instead of ctrl, but checking metaKey instead of ctrlKey isn't always enough. Something like "undo" is cmd+shift+Z by convention, and cmd + arrows is used for most navigation (though Home/End works too, and sometimes emacs-style combos too). Not that I'm demanding Mac support, but in general it might be nice to make the key-combo mapping more flexible.

You've got a nice separation of model, ui, controller and so forth, but perhaps a bit more encapsulation within each of those would be nice, such as abstracting/encapsulating the key-combo matching.

Semi-related: A long time ago, I wrote something to handle keyboard shortcuts. Maybe you can use it for something. I'm linking it mostly because for some complex combinations, key events start to get weird. I don't think the code quite works right anymore, but the technique might still be viable.

Oh, and one thing I noticed is that making a 1-column vertical selection and drawing it, gave me something like:

═
║
═

which seems a little off. I expected it to only draw vertical pipe glyphs, or, if the top and bottom should be "flat", use the 3-way pipe glyphs for the ends:

║      ╦
║  or  ║
║      ╩
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.