javascriptjquerycanvashtml5-canvasmulti-touch

Drawing on HTML5 Canvas with support for multitouch pinch, pan, and zoom


I appreciate this is not strictly a code question - but I've not quite got to that point - let me explain...

I have a requirement to enable a user to draw (as simple freehand lines) onto an large image - and be able to zoom, pan and pinch (on an iPad).

This is driving me a bit crazy. I've looked at so many libraries, code samples, products etc and there seems to be nothing out there that meets this requirement i.e. drawing (one touch) WITH (multi-touch) pinch, zoom, pan. Lots of paint.net, signature captures etc, but nothing that supports the multi-touch bit.

I have tried to adapt various libraries to acheive what I want (e.g. combining an old version of sketch.js with hammer.js) but to be honest I've struggled. I do suspect that I will have to write my own at the end of the day and use something like hammer.js (excellent by the way) for gestures.

Anyway just in case someone out there has come across a library that might fit my needs or can point me in the right direction that would be appreciated.

Feel free to give me a hard time for avoiding coding it myself ;-)


Solution

  • Custom touch.

    The example shows custom one touch draw and 2point pinch scale, rotate, pan using the standard browser touch events.

    You need to prevent the standard gestures via the CSS rule touch-action: none; on the body of the document or it will not work.

    Pointer

    The pointer object which is initialised with

    const pointer = setupPointingDevice(canvas);
    

    Handles the touch. Use pointer.count to see how many touches there are, the first touch point is available as pointer.x, pointer.y. An array of touch points can be accessed as pointer.points[touchNumber]

    View

    The is an object at the bottom that handles the view. Its just a 2D matrix with some additional functions to handle the pinch. view.setPinch(point,point) starts the pinch with the 2 points as the reference. then view.movePinch(point,point) for updates

    The view is used to draw the drawing canvas on the display canvas. To get the world (drawing coordinates) you need to convert from touch screen coordinates (canvas pixels) to the transformed drawing. Use view.toWorld(pointer.points[0]); to get the coordinates of the pinched drawing.

    To set the main canvas transform use view.apply();

    Not perfect

    Humans tend to be sloppy and the interface to the touch zoom needs to delay drawing a little bit as the 2 touches for a pinch action may not happen at once. When a single touch is detected the app starts recording drawing points. If after several frames there is no second touch then it locks into drawing mode. No touch events are lost.

    If a second touch occurs within several frames of the first it is assumed that a pinch action is being used. The app dumps any previous drawing points and set the mode to pinch.

    When the app is in draw or pinch mode they are lock until no touches are detected. This is to prevent unwanted behaviour due to sloppy touching.

    Demo

    The demo is meant only as an example.

    NOTE this will not function for non touch devices. I throw a error is no touch is found.

    NOTE I have done only the most basic of agent detection. Android, and iPhones, iPads, and anything that reports multi touch.

    NOTE Pinch events often result in two points dragging into one. This example does not handle such event correctly. You should switch to pan mode when a pinch gesture becomes a single touch and turn of rotate and scale.

        const U = undefined; 
        const doFor = (count, callback) => {var i = 0; while (i < count && callback(i ++) !== true ); };
        const drawModeDelay = 8; // number of frames to delay drawing just incase the pinch touch is
                                 // slow on the second finger
        const worldPoint = {x : 0, y : 0}; // worldf point is in the coordinates system of the drawing
        
        const ctx = canvas.getContext("2d");
        var drawMode = false;    // true while drawing
        var pinchMode = false;   // true while pinching
        var startup = true;  // will call init when true
        
        // the drawing image
        const drawing = document.createElement("canvas");
        const W = drawing.width = 512;
        const H = drawing.height = 512;
        const dCtx = drawing.getContext("2d");
        dCtx.fillStyle = "white";
        dCtx.fillRect(0,0,W,H);
        
        // pointer is the interface to the touch
        const pointer = setupPointingDevice(canvas);
        ctx.font = "16px arial.";
        if(pointer === undefined){
          ctx.font = "16px arial.";
          ctx.fillText("Did not detect pointing device. Demo terminated.", 20,20);
          throw new Error("App Error : No touch found");
    
        }
        
        // drawing functions and data
        const drawnPoints = [];  // array of draw points
        function drawOnDrawing(){  // draw all points on drawingPoint array
          dCtx.fillStyle = "black";
            while(drawnPoints.length > 0){
                const point = drawnPoints.shift();
                dCtx.beginPath();
                dCtx.arc(point.x,point.y,8,0,Math.PI * 2);
                dCtx.fill();
                dCtx.stroke();
            }
        }
        // called once at start
        function init(){
          startup = false;
          view.setContext(ctx);
        }
        // standard vars
        var w = canvas.width;
        var h = canvas.height;
        var cw = w / 2;  // center 
        var ch = h / 2;
        var globalTime;
    
    
    
        // main update function
        function update(timer){
            if(startup){ init() };
            globalTime = timer;
            ctx.setTransform(1,0,0,1,0,0); // reset transform
            ctx.globalAlpha = 1;           // reset alpha
            ctx.globalCompositeOperation = "source-over";
            if(w !== innerWidth || h !== innerHeight){
                cw = (w = canvas.width = innerWidth) / 2;
                ch = (h = canvas.height = innerHeight) / 2;
            }
            // clear main canvas and draw the draw image with shadows and make it look nice
            ctx.clearRect(0,0,w,h);
            view.apply();
            ctx.fillStyle = "black";
            ctx.globalAlpha = 0.4;
            ctx.fillRect(5,H,W-5,5)
            ctx.fillRect(W,5,5,H);
            ctx.globalAlpha = 1;
            ctx.drawImage(drawing,0,0);
            ctx.setTransform(1,0,0,1,0,0);  
            // handle touch.
            // If single point then draw
            if((pointer.count === 1 || drawMode) && ! pinchMode){
                if(pointer.count === 0){
                    drawMode = false;
                    drawOnDrawing();
                }else{
                    view.toWorld(pointer,worldPoint);
                    drawnPoints.push({x : worldPoint.x, y : worldPoint.y})
                    if(drawMode){
                        drawOnDrawing();
                    }else if(drawnPoints.length > drawModeDelay){
                        drawMode = true;
                    }
                }
            // if two point then pinch.
            }else if(pointer.count === 2 || pinchMode){
                drawnPoints.length = 0; // dump any draw points
                if(pointer.count === 0){
                    pinchMode = false;
                }else if(!pinchMode && pointer.count === 2){
                    pinchMode = true;
                    view.setPinch(pointer.points[0],pointer.points[1]);         
                }else{
                    view.movePinch(pointer.points[0],pointer.points[1]);
                }       
            }else{
                pinchMode = false;
                drawMode = false;
            }
            requestAnimationFrame(update);
        }
        requestAnimationFrame(update);
    
    
        function touch(element){
            const touch = {
                points : [],
                x : 0, y : 0,
                //isTouch : true, // use to determine the IO type.
                count : 0,
                w : 0, rx : 0, ry : 0,
      
            }
            var m = touch;
            var t = touch.points;
            function newTouch () { for(var j = 0; j < m.pCount; j ++) { if (t[j].id === -1) { return t[j] } } }
            function getTouch(id) { for(var j = 0; j < m.pCount; j ++) { if (t[j].id === id) { return t[j] } } }
    
            function setTouch(touchPoint,point,start,down){
                if(touchPoint === undefined){ return }
                if(start) {
                    touchPoint.oy = point.pageX;
                    touchPoint.ox = point.pageY;
                    touchPoint.id = point.identifier;
                } else {
                    touchPoint.ox = touchPoint.x;
                    touchPoint.oy = touchPoint.y;
                }
                touchPoint.x = point.pageX;
                touchPoint.y = point.pageY;
                touchPoint.down = down;
                if(!down) { touchPoint.id = -1 }
            }
        function mouseEmulator(){ 
            var tCount = 0;
            for(var j = 0; j < m.pCount; j ++){
                if(t[j].id !== -1){
                    if(tCount === 0){
                        m.x = t[j].x;
                        m.y = t[j].y;
                    }
                    tCount += 1;
                }
            }
            m.count= tCount;
        }  
            function touchEvent(e){
                var i, p;
                p = e.changedTouches;
                if (e.type === "touchstart") {
                    for (i = 0; i < p.length; i ++) { setTouch(newTouch(), p[i], true, true) }
                } else if (e.type === "touchmove") {
                    for (i = 0; i < p.length; i ++) { setTouch(getTouch(p[i].identifier), p[i], false, true) }
                } else if (e.type === "touchend") {
                    for (i = 0; i < p.length; i ++) { setTouch(getTouch(p[i].identifier), p[i], false, false) }
                }
                mouseEmulator();
                e.preventDefault();
                return false;
            }
            touch.pCount = navigator.maxTouchPoints;
            element = element === undefined ? document : element;
            doFor(navigator.maxTouchPoints, () => touch.points.push({x : 0, y : 0, dx : 0, dy : 0, down : false, id : -1}));
            ["touchstart","touchmove","touchend"].forEach(name => element.addEventListener(name, touchEvent) );
            return touch;
        }
        function setupPointingDevice(element){ 
            if(navigator.maxTouchPoints === undefined){ 
                if(navigator.appVersion.indexOf("Android") > -1  ||
                    navigator.appVersion.indexOf("iPhone") > -1 ||
                    navigator.appVersion.indexOf("iPad") > -1 ){
                    navigator.maxTouchPoints = 5;
                }
            }
            if(navigator.maxTouchPoints > 0){
                return touch(element);
            }else{
                //return mouse(); // does not take an element defaults to the page.
            }
        }
    
        const view = (()=>{
            const matrix = [1,0,0,1,0,0]; // current view transform
            const invMatrix = [1,0,0,1,0,0]; // current inverse view transform
            var m = matrix;  // alias
            var im = invMatrix; // alias
            var scale = 1;   // current scale
            var rotate = 0;
            var maxScale = 1;
            const pinch1 = {x :0, y : 0}; // holds the pinch origin used to pan zoom and rotate with two touch points
            const pinch1R = {x :0, y : 0};
            var pinchDist = 0;
            var pinchScale = 1;
            var pinchAngle = 0;
            var pinchStartAngle = 0;
            const workPoint1 = {x :0, y : 0};
            const workPoint2 = {x :0, y : 0};
            const wp1 = workPoint1; // alias
            const wp2 = workPoint2; // alias
            var ctx;
            const pos = {x : 0,y : 0};      // current position of origin
            var dirty = true;
            const API = {
                canvasDefault () { ctx.setTransform(1, 0, 0, 1, 0, 0) },
                apply(){ if(dirty){ this.update() } ctx.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]) },
                reset() {
                    scale = 1;
                    rotate = 0;
                    pos.x = 0;
                    pos.y = 0;
                    dirty = true;
                },
                matrix,
                invMatrix,
                update () {
                    dirty = false;
                    m[3] = m[0] = Math.cos(rotate) * scale;
                    m[2] = -(m[1] = Math.sin(rotate) * scale);
                    m[4] = pos.x;
                    m[5] = pos.y;
                    this.invScale = 1 / scale;
                    var cross = m[0] * m[3] - m[1] * m[2];
                    im[0] =  m[3] / cross;
                    im[1] = -m[1] / cross;
                    im[2] = -m[2] / cross;
                    im[3] =  m[0] / cross;
                },
                toWorld (from,point = {}) {  // convert screen to world coords
                    var xx, yy;
                    if (dirty) { this.update() }
                    xx = from.x - m[4];
                    yy = from.y - m[5];
                    point.x = xx * im[0] + yy * im[2];
                    point.y = xx * im[1] + yy * im[3];
                    return point;
                },
                toScreen (from,point = {}) {  // convert world coords to screen coords
                    if (dirty) { this.update() }
                    point.x =  from.x * m[0] + from.y * m[2] + m[4];
                    point.y = from.x * m[1] + from.y * m[3] + m[5];
                    return point;
                },
                setPinch(p1,p2){ // for pinch zoom rotate pan set start of pinch screen coords
                    if (dirty) { this.update() }
                    pinch1.x = p1.x;
                    pinch1.y = p1.y;
                    var x = (p2.x - pinch1.x);
                    var y = (p2.y - pinch1.y);
                    pinchDist = Math.sqrt(x * x + y * y);
                    pinchStartAngle = Math.atan2(y, x);
                    pinchScale = scale;
                    pinchAngle = rotate;
                    this.toWorld(pinch1, pinch1R)
                },
                movePinch(p1,p2,dontRotate){
                    if (dirty) { this.update() }
                    var x = (p2.x - p1.x);
                    var y = (p2.y - p1.y);
                    var pDist = Math.sqrt(x * x + y * y);
                    scale = pinchScale * (pDist / pinchDist);
                    if(!dontRotate){
                        var ang = Math.atan2(y, x);
                        rotate = pinchAngle + (ang - pinchStartAngle);
                    }
                    this.update();
                    pos.x = p1.x - pinch1R.x * m[0] - pinch1R.y * m[2];
                    pos.y = p1.y - pinch1R.x * m[1] - pinch1R.y * m[3];
                    dirty = true;
                },
                setContext (context) {ctx = context; dirty = true },
            };
            return API;
        })();
    canvas  {
        position : absolute;
        top : 0px;
        left : 0px;
        z-index: 2;
    }
    body {
        background:#bbb;
        touch-action: none;
    }
    <canvas id="canvas"></canvas>