Offline HTML5 canvas app in Python with django-mediagenerator, Part 2: Drawing
In part 1 of this series we've seen how to use Python/pyjs with django-mediagenerator. In this part we'll build a ridiculously simple HTML5 canvas drawing app in Python which can run on the iPad, iPhone, and a desktop browser.
Why ridiculously simple? There are a few details you have to keep in mind when writing such an app and I don't want these details to be buried under lots of unrelated code. So, in the end you will be able to draw on a full-screen canvas, but you won't be able to select a different pen color or an eraser tool, for example. These extras are easy to add even for a newbie, so feel free to download the code and make it prettier. It's a nice exercise.
Reset the viewport
With a desktop browser we could start hacking right away, but since we also support mobile browsers we need to fix something, first: One problem you face with mobile browsers is that the actual screen size is not necessarily the same as the reported screen size. In order to work with the real values we have to reset the viewport in the <head>
section of our template:
<head>
<meta name="viewport" content="initial-scale=1.0, width=device-width,
minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" />
...
</head>
Now the reported and actual screen size will be the same.
Start touching the canvas
Before we look at mouse-based drawing we first implement touch-based drawing because that has a greater influence on our design. We'll implement a simple widget called CanvasDraw
which takes care of adding the canvas element to the DOM and handling the drawing process:
from __javascript__ import jQuery, window, setInterval
class CanvasDraw(object):
# The constructor gets the id of the canvas element that should be created
def __init__(self, canvas_id):
# Add a canvas element to the content div
jQuery('#content').html('<canvas id="%s" />' % canvas_id)
element = jQuery('#' + canvas_id)
# Get position of the canvas element within the browser window
offset = element.offset()
self.x_offset = offset.left
self.y_offset = offset.top
# Get the real DOM element from the jQuery object
self.canvas = element.get(0)
# Set the width and height based on window size and canvas position
self.canvas.width = window.innerWidth - self.x_offset
self.canvas.height = window.innerHeight - self.y_offset
# Load the drawing context and initialize a few drawing settings
self.context = self.canvas.getContext('2d')
self.context.lineWidth = 8
self.context.lineCap = 'round'
self.context.lineJoin = 'miter'
# ... to be continued ...
The last two lines configure the way lines are drawn. We draw lines instead of individual points because when tracking the mouse/finger the individual positions are too far apart and thus need to be connected with lines.
In case you've wondered: The lineCap
property can be butt
, round
, or square
:
The lineJoin
property can be round
, bevel
, or miter
:
(Note: both images are modifications of images used in a Mozilla tutorial.)
Next, let's add the event handlers and the mouse/finger tracking code. The problem here is that we can't receive any touch movement events while we're drawing something on the canvas. The touch events get lost in this case and the user will experience noticeably slower drawing and bad drawing precision in general. The solution to this problem is to record the touch events in an array and then draw the the lines asynchronously via a timer which gets executed every ca. 25ms and to limit the drawing process to ca. 10ms per timer event. You can fine-tune these numbers, but they worked well enough for us. CanvasDraw
stores the mouse/finger positions in self.path
. Here's the rest of the initialization code:
class CanvasDraw(object):
def __init__(self, canvas_id):
# ... continued from above ...
# This variable is used for tracking mouse/finger movements
self.path = []
# Add asynchronous timer for drawing
setInterval(self.pulse, 25)
# Register mouse and touch events
element.bind('mouseup', self.mouse_up).bind(
'mousedown', self.mouse_down).bind(
'mousemove', self.mouse_move).bind(
'touchstart touchmove', self.touch_start_or_move).bind(
'touchend', self.touch_end)
So far so good. The actual mouse/finger movement paths are represented as a list. The path is ordered such that old entries are at the end and new entries are added to the beginning of the list. When the touch event ends we terminate the path by adding None
to the list, so multiple paths are separated by None
. Here's an example of what self.path
could look like (read from right to left):
self.path = [..., None, ..., [x1, y1], [x0, y0], None, ..., [x1, y1], [x0, y0]]
OK, let's have a look at the actual touch tracking code. We use only one method for both touchstart
and touchmove
events because they do exactly the same thing:
class CanvasDraw(object):
# ... continued from above ...
def touch_start_or_move(self, event):
# Prevent the page from being scrolled on touchmove. In case of
# touchstart this prevents the canvas element from getting highlighted
# when keeping the finger on the screen without moving it.
event.preventDefault()
# jQuery's Event class doesn't provide access to the touches, so
# we use originalEvent to get the original JS event object.
touches = event.originalEvent.touches
self.path.insert(0, [touches.item(0).pageX, touches.item(0).pageY])
When the finger leaves the screen we terminate the path with None
. Note that the touchend
event only contains the positions currently being touched, but it's fired after your finger leaves the screen, so the event.originalEvent.touches
property is empty when the last finger leaves the screen. That's why we use event.originalEvent.changedTouches
in order to get the removed touch point:
class CanvasDraw(object):
# ... continued from above ...
def touch_end(self, event):
touches = event.originalEvent.changedTouches
self.path.insert(0, [touches.item(0).pageX, touches.item(0).pageY])
# Terminate path
self.path.insert(0, None)
Drawing it
Now we can implement the actual asynchronous drawing process. Remember, we use a timer to periodically (every 25ms) draw the mouse/finger path in self.path
. We also limit the drawing process to 10ms per timer event. This is our timer:
import time
class CanvasDraw(object):
# ... continued from above ...
def pulse(self, *args):
if len(self.path) < 2:
return
start_time = time.time()
self.context.beginPath()
# Don't draw for more than 10ms in order to increase the number of
# captured mouse/touch movement events.
while len(self.path) > 1 and time.time() - start_time < 0.01:
start = self.path.pop()
end = self.path[-1]
if end is None:
# This path ends here. The next path will begin at a new
# starting point.
self.path.pop()
else:
self.draw_line(start, end)
self.context.stroke()
First we have to call beginPath()
to start a set of drawing instructions, then we tell the context
which lines to draw, and in the end we draw everything with stroke()
. The paths are processed in the while
loop by getting the oldest (last) two points from the path and connecting them with a line. When we reach the path terminator (None
) we pop()
it and continue with the next path.
The actual line drawing instructions are handled by draw_line()
. When we receive mouse/touch events the coordinates are relative to the browser window, so the draw_line()
method also converts them to coordinates relative to the canvas:
class CanvasDraw(object):
# ... continued from above ...
def draw_line(self, start, end):
self.context.moveTo(start[0] - self.x_offset, start[1] - self.y_offset)
self.context.lineTo(end[0] - self.x_offset, end[1] - self.y_offset)
Finally, we have to instantiate the widget and also prevent scrolling on the rest of our page:
canvas = CanvasDraw('sketch-canvas')
# Prevent scrolling and highlighting
def disable_scrolling(event):
event.preventDefault()
jQuery('body').bind('touchstart touchmove', disable_scrolling)
Style
We need a minimum amount of CSS code. Of course, Sass would be nicer and the media generator supports it, but then you'd also have to install Ruby and Sass which makes things unnecessarily complicated for this simple CSS snippet in reset.css
:
body, canvas, div {
margin: 0;
padding: 0;
}
* {
-webkit-user-select: none;
-webkit-touch-callout: none;
}
The first part just resets margins, so we can fill the whole screen. The last part disables text selection on the iPad and iPhone and also turns off pop-ups like the one that appears when you hold your finger on a link. That's fine for normal websites, but we normally don't want that behavior in a web app.
Of course we also need to add the file to a bundle in settings.py
MEDIA_BUNDLES = (
('main.css',
'reset.css',
),
# ...
)
and the bundle must be added to the template:
<head>
...
{% load media %}
{% include_media 'main.css' %}
</head>
What about mouse input?
Now that the infrastructure is in place the mouse tracking code is pretty straightforward:
class CanvasDraw(object):
# ... continued from above ...
def mouse_down(self, event):
self.path.insert(0, [event.pageX, event.pageY])
def mouse_up(self, event):
self.path.insert(0, [event.pageX, event.pageY])
# Terminate path
self.path.insert(0, None)
def mouse_move(self, event):
# Check if we're currently tracking the mouse
if self.path and self.path[0] is not None:
self.path.insert(0, [event.pageX, event.pageY])
Summary
Whew! As you've seen, writing a canvas drawing app with touch support is not difficult, but you need to keep more things in mind than with a mouse-based app. Let's quickly summarize what we've learned:
- Touch events get lost if the drawing instructions block the browser for too long
- Draw the image asynchronously and limit the amount of time spent on drawing
- You have to prevent scrolling in touch events via
event.preventDefault()
- On
touchend
theevent.touches
property doesn't contain the removed touch point, so you should useevent.changedTouches
- jQuery doesn't provide direct access to
event.touches
, so you have to useevent.originalEvent.touches
(same goes forevent.changedTouches
) - You have to convert mouse/touch coordinates to coordinates relative to the canvas element
- Download the latest sample code to try it out yourself
In the next part we'll make our app offline-capable and allow for installing it like a native app on the iPad and iPhone. You'll also see that django-mediagenerator can help you a lot with making your app offline-capable. Update: Part 3 is published: HTML5 offline manifests with django-mediagenerator