I have been wanting to do this for a while and just had a chance to mess around with it this week. I also had more motivation after spending Saturday in an “Designing (for) Interactions” workshop by Dan Mall, and hosted by RefreshPGH. The key concepts were HTML5, CSS3, some jQuery, and generally getting some good experience on how people in the real world design web sites. ( I really liked the boilerplate website, font squirrel, and of course A List Apart articles! )
Anyway, the thing I have been wanting to mess around with is the canvas. I have recently been working on some mobile application prototypes and I am getting familiar with some of the components that exist there. I wanted to see if I could put together a canvas demo to replicate a ScrollView, as well as being able to throw stuff in and manipulate it. So here’s my example,with a little explanation. It is a little hacked together because I put it together pretty quickly, so don’t grade on neatness and optimization — many of the functions and comments are still very much what Simon Sarris wrote and I pieced things in around his code. There are also bits and pieces of example code used from multiple places, like here.
The Details…
I started out with the code that Simon Sarris wrote for selectable shapes in a canvas. After looking and understanding what he was doing, I refactored the code to be object oriented so it was more expandable. My Goal: Understand the code well enough that I can edit it, make it expandable, and experiment with creating the “ScrollView” effect that I see on mobile devices.
Here is the canvas tag I used:
<canvas id="canvas_clickAndDragOO" width="400" height="300" style="border: 1px black solid;"> This text is displayed if your browser does not support HTML5 Canvas. </canvas>
The manipulation of the shapes is exactly how Simon wrote it, so check out his page for that explanation. The main thing I added was the scrollable content.
Key Points:
- I have two main things in the javascript: a
Canvas
class andShape
s. The shapes are pretty basic, so theCanvas
class is the most interesting. It maintains two canvas elements: one for the content and one for the view. The content canvas is never added to the view and is only maintained behind the scenes as a javascript variable. Everything is written to that content canvas and the contextdrawImage
method is used to send a section of it to the viewable canvas. - To make the view ‘scroll,’ an X and Y value are maintained to specify the coordinates of the top left corner of the viewable canvas on the content canvas. When the background of the canvas is clicked and dragged, this X and Y value are updated. The draw method simply reacts to these new values and sends an new section of the content canvas out to the viewable canvas. The viewable canvas is simply a window to a section of the content canvas.
The “content canvas” (hidden) is automatically created twice the size of the “viewable canvas” for this demo, but this and other things are configurable if you look at the Canvas object in the code. Just include this in a webpage via a script
tag and it should work with the canvas above. (UPDATE: I pasted this here so it could be read and copied easier)
UPDATE 2: This code doesn’t work on < IE8 using the excanvas.js library because that library does not support the getImageData
function! I would have to find a way around that in order for it to be backwards compatible. Also, older IE browsers do not support the pageX and pageY properties on the mouse events. This issue can be solved quickly by looking here (search the page for “pageX”).
UPDATE 3: I cannot run a demo on this wordpress site, so here is a link to a zip file demo. Unzip it and open index.html in your browser. This is on my new site … same blog, but i’m yet to redirect everything there…
/* * My object oriented version of click and drag * */ function Canvas( data ) { data = data || {}; var self = this; this.canvasID = data.canvasID || 'canvas'; this.viewableCanvas = document.getElementById(this.canvasID); this.vctx = data.canvasContext || this.viewableCanvas.getContext('2d'); // set our events. Up and down are for dragging, // double click is for making new boxes this.viewableCanvas.onmousedown = function(e){ Canvas.prototype.mouseDownAction.call( self, e ) }; this.viewableCanvas.onmouseup = function(e){ Canvas.prototype.mouseUpAction.call( self, e ) }; this.viewableCanvas.onmouseout = function(e){ Canvas.prototype.mouseUpAction.call( self, e ) }; this.viewableCanvas.ondblclick = function(e){ Canvas.prototype.mouseDblClickAction.call( self, e ) }; this.isValid = Canvas.IS_INVALID; this.canvasContentObjectsList = []; // we use a content canvas to draw individual shapes. This is larger than the viewable canvas. // only a section of this is passed to the viewable canvas var contentCanvasHeight = data.contentCanvasHeight || this.viewableCanvas.height * 2; if (contentCanvasHeight < this.viewableCanvas.height) { contentCanvasHeight = this.viewableCanvas.height } var contentCanvasWidth = data.contentCanvasWidth || this.viewableCanvas.width * 2; if (contentCanvasWidth < this.viewableCanvas.width) { contentCanvasWidth = this.viewableCanvas.width } this.contentCanvas = document.createElement('canvas'); this.contentCanvas.height = contentCanvasHeight; this.contentCanvas.width = contentCanvasWidth; this.cctx = this.contentCanvas.getContext('2d'); // content context // These X and Y values are the top left corner of the "viewable window" of this content canvas this.ccViewX = Math.max( (contentCanvasWidth/2) - (this.viewableCanvas.width/2), 0 ); this.ccViewY = Math.max( (contentCanvasHeight/2) - (this.viewableCanvas.height/2), 0 ); // Max values for the ccView X and Y vals this.ccViewXMax = this.cctx.canvas.width - this.vctx.canvas.width; this.ccViewYMax = this.cctx.canvas.height - this.vctx.canvas.height; this.isContentObjectDragAction = false; this.isContentCanvasDragAction = false; // the X and Y coordinate for when the mousedown event is triggered this.contentCanvasDragStartX = 0; this.contentCanvasDragStartY = 0; //fixes a problem where double clicking causes text to get selected on the canvas this.viewableCanvas.onselectstart = function () { return false; } // fixes mouse co-ordinate problems when there's a border or padding // see getMouse for more detail this.stylePaddingLeft = 0; this.stylePaddingTop = 0; this.styleBorderLeft = 0; this.styleBorderTop = 0; if (document.defaultView && document.defaultView.getComputedStyle) { this.stylePaddingLeft = parseInt(document.defaultView.getComputedStyle(this.viewableCanvas, null)['paddingLeft'], 10) || 0; this.stylePaddingTop = parseInt(document.defaultView.getComputedStyle(this.viewableCanvas, null)['paddingTop'], 10) || 0; this.styleBorderLeft = parseInt(document.defaultView.getComputedStyle(this.viewableCanvas, null)['borderLeftWidth'], 10) || 0; this.styleBorderTop = parseInt(document.defaultView.getComputedStyle(this.viewableCanvas, null)['borderTopWidth'], 10) || 0; } // The selection color and width. Right now we have a red selection with a small width this.selectedColor = data.selectedColor || '#CC0000'; this.selectedWidth = data.selectedwidth || 2; // since we can drag from anywhere in a node // instead of just its x/y corner, we need to save // the offset of the mouse when we start dragging. this.offsetx = this.ccViewX; this.offsety = this.ccViewY; this.currentlySelectedContentObject = null; // make draw() fire every INTERVAL milliseconds setInterval(function() { Canvas.prototype.draw.call( self ) }, 1000/(data.interval?data.interval:40) ); } // static members Canvas.IS_VALID = true; Canvas.IS_INVALID = false; // create a random number between two ints, inclusive Canvas.randomFromTo = function( from, to ) { return Math.floor(Math.random() * (to - from + 1) + from); } //wipes the canvas context Canvas.prototype.clear = function( ctx ) { ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); } // While draw is called as often as the INTERVAL variable demands, // It only ever does something if the canvas gets invalidated by our code Canvas.prototype.draw = function( ) { if ( !this.isValid && this.cctx ) { //Canvas.prototype.clear.call( this, ctx ); this.clear( this.cctx ); // Add stuff you want drawn in the background all the time here //Draw a grid to assist in seeing the background scroll this.drawGrid(); // draw all boxes var l = this.canvasContentObjectsList.length; for (var i = 0; i < l; i++) { this.canvasContentObjectsList[i].drawshape({ ctx: this.cctx }); } // draw selection // right now this is just a stroke along the edge of the selected box var selectedObj = this.currentlySelectedContentObject; if (selectedObj != null) { selectedObj.highlightBorder({ ctx: this.cctx, selectedColor: this.selectedColor, selectedWidth: this.selectedWidth }); } // copy section of content canvas out to the viewable canvas try { this.clear( this.vctx ); this.vctx.drawImage( this.cctx.canvas, this.ccViewX, this.ccViewY, this.viewableCanvas.width, this.viewableCanvas.height, 0, 0, this.viewableCanvas.width, this.viewableCanvas.height ) } catch(e) { // Do nothing. a draw error may occur because we are updating this canvas // before the cctx.canvas is done drawing. } // Add stuff you want drawn on top all the time here this.isValid = Canvas.IS_VALID; } } // draw grid lines, but only on the part visible Canvas.prototype.drawGrid = function( data ) { // Draw gridlines to help with scrolling for (var x = (this.ccViewX + 0.5); x < (this.ccViewX + this.vctx.canvas.width); x += 40) { this.cctx.moveTo(x, 0); this.cctx.lineTo(x, this.ccViewY + this.vctx.canvas.height); } for (var y = (this.ccViewY + 0.5); y < (this.ccViewY + this.vctx.canvas.height); y += 40) { this.cctx.moveTo(0, y); this.cctx.lineTo(this.ccViewX + this.vctx.canvas.width, y); } this.cctx.strokeStyle = "#eee"; this.cctx.stroke(); } // Sets mx,my to the mouse position relative to the canvas // unfortunately this can be tricky, we have to worry about padding and borders Canvas.prototype.getMouseCoordinates = function(e) { var element = this.viewableCanvas, offsetX = 0, offsetY = 0; if (element.offsetParent) { do { offsetX += element.offsetLeft; offsetY += element.offsetTop; } while ((element = element.offsetParent)); } // Add padding and border style widths to offset offsetX += this.stylePaddingLeft; offsetY += this.stylePaddingTop; offsetX += this.styleBorderLeft; offsetY += this.styleBorderTop; var mx = e.pageX - offsetX + this.ccViewX; var my = e.pageY - offsetY + this.ccViewY; // These are the mouse coordinates on the VIEWABLE CANVAS return { mouseX: mx, mouseY: my }; } // Happens when the mouse is clicked in the canvas Canvas.prototype.mouseDownAction = function(e){ var self = this; var mouseMoveFunction = function(e){ Canvas.prototype.mouseMoveAction.call( self, e ) }; var mouseCoords = this.getMouseCoordinates(e); //Canvas.prototype.clear.call( this, ctx ); this.clear( this.cctx ); var l = this.canvasContentObjectsList.length; for (var i = l-1; i >= 0; i--) { // draw shape onto ghost context this.canvasContentObjectsList[i].drawshape({ ctx: this.cctx, fill: 'black' }); // get image data at the mouse x,y pixel var imageData = this.cctx.getImageData(mouseCoords.mouseX, mouseCoords.mouseY, 1, 1); //var index = (mouseCoords.mouseX + mouseCoords.mouseY * imageData.width) * 4; // if the mouse pixel exists, select and break if (imageData.data[3] > 0) { this.currentlySelectedContentObject = this.canvasContentObjectsList[i]; this.offsetx = mouseCoords.mouseX - this.currentlySelectedContentObject.x; this.offsety = mouseCoords.mouseY - this.currentlySelectedContentObject.y; this.currentlySelectedContentObject.x = mouseCoords.mouseX - this.offsetx; this.currentlySelectedContentObject.y = mouseCoords.mouseY - this.offsety; this.isContentObjectDragAction = true; this.viewableCanvas.onmousemove = mouseMoveFunction; this.isValid = Canvas.IS_INVALID; //Canvas.prototype.clear.call( this, ctx ); this.clear( this.cctx ); return; } } // Register a drag action for the whole canvas if ( !this.isContentCanvasDragAction ) { this.contentCanvasDragStartX = mouseCoords.mouseX; this.contentCanvasDragStartY = mouseCoords.mouseY; this.originalCCViewX = this.ccViewX; this.originalCCViewY = this.ccViewY; } this.isContentCanvasDragAction = true; this.viewableCanvas.onmousemove = mouseMoveFunction; // havent returned means we have selected nothing this.currentlySelectedContentObject = null; // clear the ghost canvas for next time //Canvas.prototype.clear.call( this, ctx ); this.clear( this.cctx ); // invalidate because we might need the selection border to disappear this.isValid = Canvas.IS_INVALID; } // Happens when the mouse is moving inside the canvas Canvas.prototype.mouseMoveAction = function(e){ var mouseCoords = null; if ( this.isContentObjectDragAction ){ mouseCoords = this.getMouseCoordinates(e); this.currentlySelectedContentObject.x = mouseCoords.mouseX - this.offsetx; this.currentlySelectedContentObject.y = mouseCoords.mouseY - this.offsety; // something is changing position so we better invalidate the canvas! this.isValid = Canvas.IS_INVALID; } if ( this.isContentCanvasDragAction ) { mouseCoords = this.getMouseCoordinates(e); xChange = (mouseCoords.mouseX - this.contentCanvasDragStartX); yChange = (mouseCoords.mouseY - this.contentCanvasDragStartY); // Must move 30 pixels before scrolling. if (Math.abs(xChange) > 30 || Math.abs(yChange) > 30) { var newX = this.originalCCViewX - xChange; newX = newX < 0 ? 0 : newX; newX = newX > this.ccViewXMax ? this.ccViewXMax : newX; this.ccViewX = newX; var newY = this.originalCCViewY - yChange; newY = newY < 0 ? 0 : newY; newY = newY > this.ccViewYMax ? this.ccViewYMax : newY; this.ccViewY = newY; this.isValid = Canvas.IS_INVALID; } } } Canvas.prototype.mouseUpAction = function(){ this.isContentObjectDragAction = false; this.isContentCanvasDragAction = false; this.viewableCanvas.onmousemove = null; } Canvas.prototype.addContentObject = function( obj ) { // Verify that this is the correct object type so we have X and Y coordinates this.canvasContentObjectsList.push( obj ); this.isValid = Canvas.IS_INVALID; } // adds a new node Canvas.prototype.mouseDblClickAction = function(e) { var mouseCoords = this.getMouseCoordinates(e); var randomColor = '#'+(Math.random()*0xFFFFFF<<0).toString(16); switch( Canvas.randomFromTo( 1, 3 ) ) { case 1: this.addContentObject( new Circle({ x: mouseCoords.mouseX, y: mouseCoords.mouseY, r: Canvas.randomFromTo( 20, 50 ), fill: randomColor }) ); break; case 2: this.addContentObject( new Rectangle({ x: mouseCoords.mouseX, y: mouseCoords.mouseY, w: Canvas.randomFromTo( 20, 100 ), h: Canvas.randomFromTo( 20, 100 ), fill: randomColor }) ); break; case 3: default: this.addContentObject( new Triangle({ x: mouseCoords.mouseX, y: mouseCoords.mouseY, w: Canvas.randomFromTo( 20, 100 ), h: Canvas.randomFromTo( 20, 100 ), fill: randomColor }) ); break; } } /*********************************************************** * Shape super class * ***********************************************************/ function Shape( data ) { data = data || {}; this.shape = data.shape || 'Shape'; this.shapeID = data.id || 'Shape'; this.fill = data.fill || '#444444'; this.x = data.x; this.y = data.y; } // Draws a single shape to a single context // draw() will call this with the normal canvas // myDown will call this with the ghost canvas Shape.prototype.drawshape = function( data ) { if (!data) return; // fill can be overridded if passed in data.ctx.fillStyle = data.fill || this.fill; // subclass specific } Shape.prototype.highlightBorder = function( data ) { // Handle the basic, standard highlight things here. if (!data) return; data.ctx.strokeStyle = data.selectedColor; data.ctx.lineWidth = data.selectedWidth; } /*********************************************************** * Rectangle super class * ***********************************************************/ // Create subclass and reassign constructor to self. Rectangle.prototype = new Shape(); Rectangle.prototype.constructor = Rectangle; function Rectangle( data ) { data = data || {}; Shape.call( this, data ); this.w = data.w; this.h = data.h; } // Draws a single shape to a single context // draw() will call this with the normal canvas // myDown will call this with the ghost canvas Rectangle.prototype.drawshape = function( data ) { Shape.prototype.drawshape.call(this, data); // We can skip the drawing of elements that have moved off the screen: if (this.x > data.ctx.canvas.width || this.y > data.ctx.canvas.height) return; if (this.x + this.w < 0 || this.y + this.h < 0) return; data.ctx.fillRect(this.x,this.y,this.w,this.h); } Rectangle.prototype.highlightBorder = function( data ) { Shape.prototype.highlightBorder.call(this, data); data.ctx.strokeRect(this.x,this.y,this.w,this.h); } /*********************************************************** * Circle super class * ***********************************************************/ // Create subclass and reassign constructor to self. Circle.prototype = new Shape(); Circle.prototype.constructor = Circle; function Circle( data ) { data = data || {}; Shape.call( this, data ); this.r = data.r; //radius } // Draws a single shape to a single context // draw() will call this with the normal canvas // myDown will call this with the ghost canvas Circle.prototype.drawshape = function( data ) { Shape.prototype.drawshape.call(this, data); // We can skip the drawing of elements that have moved off the screen: if (this.x > data.ctx.canvas.width || this.y > data.ctx.canvas.height) return; if (this.x + this.r < 0 || this.y + this.r < 0) return; this.drawCircle( data ); } Circle.prototype.highlightBorder = function( data ) { Shape.prototype.highlightBorder.call(this, data); data['borderOnly'] = true; this.drawCircle( data ); } Circle.prototype.drawCircle = function( data ) { // Draw a circle using the arc function. data.ctx.beginPath(); // Arguments: x, y, radius, start angle, end angle, anticlockwise data.ctx.arc(this.x, this.y, this.r, 0, 360, false); if ( !data['borderOnly'] ) { data.ctx.fill(); } else { data.ctx.stroke(); } data.ctx.closePath(); } /*********************************************************** * Triangle super class * ***********************************************************/ // Create subclass and reassign constructor to self. Triangle.prototype = new Shape(); Triangle.prototype.constructor = Triangle; function Triangle( data ) { data = data || {}; Shape.call( this, data ); this.w = data.w; this.h = data.h; } // Draws a single shape to a single context // draw() will call this with the normal canvas // myDown will call this with the ghost canvas Triangle.prototype.drawshape = function( data ) { Shape.prototype.drawshape.call(this, data); // We can skip the drawing of elements that have moved off the screen: if (this.x > data.ctx.canvas.width || this.y > data.ctx.canvas.height) return; if (this.x + this.w < 0 || this.y + this.h < 0) return; this.drawTriangle( data ); } Triangle.prototype.highlightBorder = function( data ) { Shape.prototype.highlightBorder.call(this, data); data['borderOnly'] = true; this.drawTriangle( data ); } Triangle.prototype.drawTriangle = function( data ) { data.ctx.beginPath(); // Start from the top-left point. data.ctx.moveTo( this.x, this.y - (this.h / 2) ); // give the (x,y) coordinates data.ctx.lineTo( this.x + (this.w / 2), this.y + (this.h / 2) ); data.ctx.lineTo( this.x - (this.w / 2), this.y + (this.h / 2) ); data.ctx.lineTo( this.x, this.y - (this.h / 2) ); // Done! Now fill the shape, and draw the stroke. // Note: your shape will not be visible until you call any of the two methods. if ( !data['borderOnly'] ) { data.ctx.fill(); } else { data.ctx.stroke(); } data.ctx.closePath(); } // start it up! window.onload = function(){ var OO_canvas = new Canvas( { canvasID: 'canvas_clickAndDragOO' }); var r1 = new Rectangle({ x: 800, y: 700, w: 40, h: 70, fill: '#FFC02B' }); OO_canvas.addContentObject( r1 ); var r2 = new Rectangle({ x: 780, y: 780, w: 70, h: 40, fill: '#2BB8FF' }); OO_canvas.addContentObject( r2 ); var t1 = new Triangle({ x: 700, y: 600, w: 70, h: 40, fill: '#900' }); OO_canvas.addContentObject( t1 ); var t2 = new Triangle({ x: 900, y: 600, w: 70, h: 40, fill: '#009' }); OO_canvas.addContentObject( t2 ); var c1 = new Circle({ x: 675, y: 730, r: 30, fill: '#459' }); OO_canvas.addContentObject( c1 ); var c2 = new Circle({ x: 950, y: 730, r: 30, fill: '#954' }); OO_canvas.addContentObject( c2 ); };
June 14th, 2011 at 1:06 pm
this is real cool. i was thinking about how to do this (not dedicating much time) type of scrolling. i found, i study, i do.
thanks
June 14th, 2011 at 1:37 pm
I know what you mean, it took a while for me to find the time to mess with it.
I’m not sure if it is the best way, but i had trouble finding it elsewhere. Maybe I didn’t search for the right terms.
Glad you like it!
July 22nd, 2011 at 2:05 pm
Good work!
You should get a syntax highlighter like the one I use to make things a bit more readable:
http://wordpress.org/extend/plugins/wp-syntax/
July 22nd, 2011 at 3:53 pm
Thanks Simon! Thanks for the original code to help me get started!
I will check that out highlighter. It is pretty hard to read — I put it on pastie to help that a little..
August 2nd, 2011 at 1:37 pm
Do you have a demo of this anywhere…?
August 2nd, 2011 at 2:30 pm
I just added UPDATE 3 that has a link to a zip file. You can open the index.html file directly in your browser and it will run.
October 12th, 2012 at 4:18 am
Hi there! It looks like the ZIP file link is no longer active. Could you repost? Thank you!
October 12th, 2012 at 12:41 pm
hmm… yeah. thanks free download site for dropping that. Let me dig that up and see if i can get it back up here. There is a link to the code itself up there under one of the “update” sections
October 12th, 2012 at 6:30 pm
ok, sorry it took me a while… things to do!
Anyway i have updated the link to link to my own site mpickell.com. It should not fail anymore.
http://mpickell.com/public/CanvasDemo.zip
October 12th, 2012 at 6:40 pm
and… not sure why i didn’t do this earlier… here it is online:
http://mpickell.com/public/CanvasDemo/index.html