HTML5 canvas synchronisation with Konva, part 2, peer-to-peer

In part 2 of my hacking a way to synchronise HTML5 canvases across browser windows, I’m taking the results from part 1 and making them work between browser windows via communications lib PeerJS.

There’s not much to look at here so you might want to jump to the code at the bottom & cut & paste it into your favourite editor then follow along as I explain it.

I’ve already completed the code so I first wanted to recap where I left off in part 1 and highlight some of the design changes that emerged as part 2 developed.

A quick recap on the design

My end goal is a meeting or classroom-style whiteboard where there could be multiple users sharing and co-operating on a Konva diagram. It could have a presentation mode where only one user could draw and all others were restricted to viewing, or a co-drawing mode where 2 or more users could draw at the same time.

The architecture is for each user to have a browser with a web page hosting a Konva instance – Konva is a wrapper on steroids for the HTML5 canvas. There is no natural sharing mechanism built-in to Konva, which is fine with me because I want Konva to do the one job of being a canvas lib, which it does very well.

The approach in Part 1

In part 1 the focus was on how to catch changes to the canvas as elegantly as possible, keeping as much to the plain Konva API as possible and leaving the browser-to-browser step until part 2.

I set up a couple of functions for shape creation and assignment to a container, which I couldn’t achieve generically. Then I used a JavaScript proxy to observe changes to the Konva.Shape.attrs[] array, which contains many of the properties of any visual shape. Any time a shape was changed, for example setting the shapes fill color, would result in a change to an attribute observed by the proxy which I would catch and broadcast to the following canvases.

This worked for part-1, but as part-2 progressed a major issue emerged- there are too many individual property changes causing too many messages to be sent. Why is this an issue? Whilst this worked when the two canvases were in the same browser, communication cross-browser is relatively slow and builds a backlog causing lags in the receiving browsers.

The remedy has been to combine changes to turn maybe 5 messages into one. The inspiration for this came from coding the transformer changes. The transformer alters properties for the shape’s position(x,y), size(w, h), rotation(angle), scaleX(scale) and scaleY(scale). In the part-1 approach that would lead to 5 messages per individual transformer change. Combining them to a single massage takes more code but reduces the message load to only one message – a much better message rate that reduces the lag.

I’ve adopted a similar approach for visual style changes – fill, stroke, strokeWidth, and strokeEnabled. This approach could be expanded for shadows, patterns, etc.

I also switched to a more pure message-format-based approach for processing messages at the followers and realised that this could be useful at the presenter too. The down-side of this is that it requires moving away from pure Konva API – see the code example later on. I consider the change viable as it retains the sequence of Konva setup of stage, layers & shapes, and retains the simplicity of Konva overall.

That said, I’m still using the proxy to observe changes to the shape attributes array, but I’m filtering out and disregarding changes to anything that I’ve covered in the combination messages. This acts as a useful catch-all, and I’ll be watching the message exchange looking for any overly-repeating messages and considering them for a combination replacement.

Adding peer-to-peer with PeerJS

I didn’t want to add the complexity of having a server between the peers (browsers) but ultimately there has to be some central exchange where each browser can signal that they are available for connections. I chose PeerJS because it provides a lightweight server service – I wouldn’t deploy to Production relying on a third-party server, but I’m going with it for this R&D exercise.

I’ve coded the PeerJS part as a JavaScript object so it should be replaceable with similar plumbing for other peer-to-peer libs as needed, particularly with the event-bus approach to event handling.

I copied the communications code from the (only) example in the PeerJS website. This example has different code for sender and receiver – I wanted to make each peer capable of both, but otherwise my code follows theirs very closely. I wrapped the PeerJS code in a JS object and used a very simple event bus to avoid having to intertwine the external code into the PeerJS wrapper. Ultimately the PeerJS wrapper focusses exclusively on the communications axis, raising and broadcasting events via the bus which the external code reacts to. I tend to find that when I get involved in something with a lot of async it is easier to use an event bus to keep the callback process tidy.

Anatomy of the solution

The code consists of one page composed of:

  • a few HTML elements,
  • a JavaScript object for the overall sync handler,
  • a JavaScript object for the PeerJS wrapper,
  • a JavaScript object for the event bus.
  • The page-related code that instantiates the objects and executes the initial Konva drawing commands.

The handler object – syncHandler

The syncHandler object is there to corral the code – literally just to keep it contained and recognisably separate from the rest. At a high level, the syncHandler’s job is to

  • catch changes to the Konva diagram and send to the following peers;
  • react to changes sent in from the peers
  • fronting the peerCom object for simplicity (e.e. join() & send() functions)

The syncHandler needs to be informed about the Konva stage object, which is accomplished with the setStage() function. Otherwise the beating heart of it is the processMessage() function.

Here’s a point to consider: processMessage() is used both to create the Konva content AND to process the messages from other peers about their Konva content. It’ll help to think about the evolution of the code at this point. In my part-1 code, I had ‘creator’ code blocks for the konva content, then receiving code to handle messages from other peers about their konva shape creation. movement, etc. Then I realised that the intent of both was the same and decided to switch to this approach – meaning a single code path replacing two which is much easier to live with and debug.

So – processMessage() handles what each message means – making shapes, assigning to layers, transforming, moving, changing visual stype, etc. This would be an area to extend for other messages / konva changes.

The PeerJS wrapper object – peerCom

As I mentioned, the guts here are based on the PeerJS example. To operate, PeerJS has a ‘peer’ and connections. To do anything, your code needs a peer – that’s what the initialize() function creates. It also checks in with the lightweight PeerJS server, registering that it is present and ready for connections. Conversations between peers happen through connections. A peer has to ask another peer to open a connection – the other peer can refuse, etc. Once you have a connection you can send messages. The join() function opens a connection to the specified other peer. Both initialize() and join() use the configureConnection() function to wire up most of the connection events – the PeerJS example had these as separate but they seemed to me to cover the same ground.

In the configureConnection() function there is an event handler for the ‘data’ event – which is fired when a connection sends a message into the peer.

Meanwhile the sendMessage() function in the peerjs wrapper does the actual sending of messages to the attached peers.

In my imagined solution, I have one peer acting as presenter, while others act as followers who watch the presenter. Actually I coded it so that you can drag and transform shapes on any of the peers and the changes happen on all others. But what is happening under the covers is that each follower is connected to the presenter and any messages generated from a follower are sent to the presenter who distributes them to the followers. I coded it like that to avoid the need for all peers to connect to every other, but you can do whatever you need, PeerJS makes it quite easy. Long story short, you will see in the sendMessage() function that there is a for loop on the peer’s connections, implying correctly that a peer could have multiple connections, though in reality in my solution only the presenter does – the followers have exactly one connection to the presenter.

The event bus – EventBus

There’s plenty of explanations about event buses. Whenever the code I am working on does a lot of async work I find an event-bus-based approach maintains readable code. I came across a blog post about a super simple way of coding a bus in a few lines and thought this was an ideal project to try it out.

The main point of the bus is that you can set a listener just like setting a listener for a button click. In this project I listen for PeerJS connection events. It works like a charm.

The page code

The main business of the page starts around line 520. We create the handler, then the Konva Stage, set a listener for the peerOpen event which gets fired as a result of the syncHandler connect() call. We then tell the syncHandler about the stage.

// Create the sync handler object.
let handler = new syncHandler();

// Making the stage is standard Konva API code.
let stage = new Konva.Stage({container: "container", width: $('#container').width(),  height: $('#container').height()});

// when the peer connects to the server we set the visible peer id value element.
myEventBus.on('peerOpen', function(detail){
    $('#peerId').val(detail.detail.peerId);
}); 
handler.connect(); // connect to the peer connection exchange - this is async - raises the peerOpen bus event

// While the connection is being prepared async, tell the handler about our local stage.
handler.setStage(stage); 

After this there are a couple of listeners and a function for connection events that update the UI, and the all-important listener for data incoming from the peer connections.

// Add a listener to be fired when change messages are received.
myEventBus.on('connectionData', function(messageObj){ 

    handler.processMessage(messageObj.detail.msg);

});  

At this point it’s important to recall that this peerjs stuff is async – we need to wait for connections before we do much else. I’ve taken a cheap design route in this project meaning that I don’t store the Konva content as it is created – the followers have to be in place from the start of the Konva drawing otherwise they don’t see what happened before they arrived. To overcome this would need a means to quickly send messages to a new follower for the canvas as it looked at the point that they joined. As I said, I am dodging that for now.

The setupCanvas() function is fired by the ‘Draw’ button. You can see that like the following extract of layer creation, the Konva API commands are replaced by messages that encapsulate the parameters of the Konva commands, but fired into the syncHandler() as messages, formatted with a message type and associated parameters.

// Make a layer
let msg = {type: 'makeShape', typeName: 'Layer', attrs: { draggable: true}};
let layer = handler.processMessage(JSON.stringify(msg));

// Add the layer to the stage. 
msg = {type: 'changeParent', id: layer.id(), parentId: stage.id(), parentType: stage.getType()};
handler.processMessage(JSON.stringify(msg));

// Make a rect.
msg = {type: 'makeShape', typeName: 'Rect', attrs: {name: 'r1', x: 20, y: 10, width: 100, height: 80, fill: 'cyan', draggable: true}};
let rect1 = handler.processMessage(JSON.stringify(msg));

// Add rect1 to layer via syncHandler
msg = {type: 'changeParent', id: rect1.id(), parentId: layer.id(), parentType: layer.getType()};
handler.processMessage(JSON.stringify(msg));

The setupTransforming() function is needed because this demo provides a click & drag means of adding shapes on the stage into a transformer. This requires a rect to mark the page as the selection rect is created, and this rect has to have mouse events wired up which we cannot do via messages. Therefore each peer needs to enable these events itself on its own canvas – there’s a message to assign a given rect for this purpose.

Finally the code has the buttons to power drawing, making connections between peers (Join button) and clearing the trace messages.

How to use it

Save the code on your own disk – no web server required. Start a minimum of 2 browsers and load the page in each. When each page starts it will display a peer id. On the browser that will be the presenter, one-by-one paste the peer id’s from the other browsers into the ‘connect peer id’ box and click the ‘Join’ button. The peer id will move into the ‘connections’ box showing that the connection is live.

When all the browsers are connected to the presenter, click the ‘Draw’ button on the presenter browser. Some shapes will appear on all browsers and various trace messages will appear below the grey canvas on each browser.

You can click and drag the shapes on any of the browsers and the others will follow along.

You can click & drag on the stage to select shapes to transform, stretching and rotating them individually or as a group – again the other browsers will follow along.

The code

The code for this article is posted here in full. Regular readers will know that I usually use CodePen but I felt that CodePen was getting in the way of performance on this one so I provide the code for you to place on your own machine and load from disk.

<html>
<head>
<style>
.container {
  width: 800px;
  height: 300px;
  background-color: silver;
  margin: 5px;
}
#trace {
  max-height: 200px;
  overflow-y: scroll;
  font-family: 'Courier'
}
pre {
  margin: 0;
}
.input {
  width: 350px;
}
</style>
</head>
<body>


<p><span>This peer id: </span><input id='peerId' class='input'/></p>
<p><span>Connect peer id: </span><input id='newConnectionId' class='input' value="" placeHolder="Paste peer id here"/><button id='join'>Join</button></p>
<p><span id='role'></span></p>
<p><span>Connections: </span><input id='connections' class='input'/></p>
<p><input type='checkbox' id='traceOn' checked='true'/><label for='traceOn'> Trace messages</label> <button id='draw'>Draw</button> <button id='clear'>Clear trace</button></p>
<div id='container' class='container'>
</div>
<p id='trace'></p>

 
</body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js" integrity="sha512-894YE6QWD5I59HgZOGReFYm4dnWc1Qt5NtvYSaNcOP+u1T9qYdvdihz0PPSiiqn/+/3e7Jo4EaG7TubfWGUrMQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://unpkg.com/konva@8/konva.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/peerjs/1.3.2/peerjs.min.js" integrity="sha512-4wTQ8feow93K3qVGVXUGLULDB9eAULiG+xdbaQH8tYZlXxYv9ij+evblXD0EOqmGWT8NBTd1vQGsURvrQzmKeg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script>

$( document ).ready(function() {

/*
* This is an uber-lightweight event bus technique - see https://css-tricks.com/lets-create-a-lightweight-native-event-bus-in-javascript/ for details.
*/


function EventBus(description){
    let EventTarget;

    this.eventTarget = document.appendChild(document.createComment(description));

    this.on = function(type, listener){
        this.eventTarget.addEventListener(type, listener);
    }
    this.once = function(type, listener){
        this.eventTarget.addEventListener(type, listener,  { once: true });
    }
    this.off = function(type, listener){
        this.eventTarget.removeEventListener(type, listener);
    }
    this.emit = function(type, detail){
        this.eventTarget.dispatchEvent(new CustomEvent(type, { detail }));
    }    
}
const myEventBus = new EventBus('my-event-bus');
   


/*
 * SyncHandler is a DIY object for synchronising canvases.   
 * An object is used to scope the code. Create new instaces via
 * let myHandler = new syncHandler();  
 * Then
 * myHanlder.setStage(stg); // where stg is an instance of a Konva.Stage
 */
function syncHandler(){
  
    let stage = null,  
        shapes = {},        // Each shape created is placed in this object thus forming a quick lookup list based on id.
        ignoreAttrs = {
            x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1, width: 0, height: 0, fill: '', stroke: '', strokeWidth: 0, strokeEnabled: false
        };

    this.selectionRectangle = null;
    this.role = "waiting";

    let that = this; // lots of async code here which makes relying on 'this' tricky - set a variable we can rely on !

    this.setStage = function(stageIn){
        stage = stageIn;
        stage.id(getGuid());  // set the stage id  
    }
   
    // Connect the peer to the server (exchange) so it can receive connections.
    this.peerObj = null;

    this.connect = function(){

        trace('handler.connect')
        this.peerObj = new peerCom(); // creates peer and connects to connection server
        trace('connect peerObj = ' + this.peerObj)
    }

    this.join = function(targetPeerId){
        this.peerObj.join(targetPeerId);
    }

    let send = function(msg){
 
        if (that.peerObj){

            if (that.role === 'follower' && ('src' in msg)){
                //'Msg from diif src - no send 

                return;
            }

            that.peerObj.sendMessage(msg, msg.src);
        }
    }
    this.send = send;


  // We need all shapes to have a unique ID so we use this func to make them. 
  // These are not entirely compliant GUID's but meet our needs.
  function getGuid(){
      let fC=function () {
        return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1).toUpperCase();
      }
      return (fC() + fC() + "-" + fC() + "-" + fC() + "-" + fC() + "-" + fC() + fC() + fC());
  }



  
  // Func to make a proxy for the shape's attrs arry.
  function makeProxy(shape, target){
    let prxy =  new Proxy(target, {

        // this code fires when the shape has a change to its attrs array. It allows us 
        // to intervene with the prop change, send the change to the listerners
        // target is the shape's attrs array which will shortly receive the new value.
        set(array, prop, value, target) {

            let currentVal = shape.getAttr(prop),
                sendIt = true; // flag to indicate change is to be sent - overridden below for numeric types if needed.

            if (prop in ignoreAttrs){
                sendIt = false;
            }
            else {
                if (currentVal !== value){
                    if ( typeof(value) === "number" ){         
                        if (Math.abs(currentVal - value) < 0.01){  // adjust or remove this tollerence as needed.
                        sendIt = false;
                        }
                    }
                    if (sendIt){
                        // make the message
                        let msg = {type: 'changeAttr', id: target.id};
                        msg[prop] = value;
                      //  trace("** send: " + JSON.stringify(msg))
                        that.processMessage(JSON.stringify(msg));
                    }
                }            
            }
 
          // finally let the target - the shape's attr array get the change. 
          return Reflect.set(array, prop, value, target);

        },
        deleteProperty(array, prop) {
          // Included in case needed in future.
          let msg = {type: 'attrdel', name: prop};
          send(msg);      

          // finally let the target - the shape's attr array get the change. 
          return Reflect.deleteProperty(array, prop);
        }
      })
      prxy.shape = shape;
    return prxy;
  }
  
    
  function applyIfSet(prop, configObj, targetObj){
    if (prop in configObj){
        targetObj[prop](configObj[prop]);
    }
  }
 

  /* In this func we process a received change message. We receive a message in JSON format 
   * containing the change information. The 'type' value (e.g. 'makeShape', 'changeParent', 'changeAttr') is used 
   * to determine the action to be taken. 
  */
  this.processMessage = function(changeInfo){

    let change = JSON.parse(changeInfo), // parse the JSON into a JS object. Note you will want  a try-catch here !
        shape = shapes[change.id];

 
    if  (change.type === "makeShape"){ // a new shape message - handled slightly differently because all other messages relate to an existing shape.
 
        let shape = null;

        change.attrs = (typeof change.attrs == 'undefined' ? {} : change.attrs); // if attrs is not supplied then make an empty array
        change.attrs.id = (typeof change.attrs.id === 'undefined' ? getGuid() : change.attrs.id); // ensure there is an ID.
                
        shape = new Konva[change.typeName](); // Make the actual Konva shape in this canvas.

        shape.setAttrs(change.attrs);  // Set the attrs for the new shape.  
        shape.attrs =  makeProxy(shape, shape.attrs);
        if (typeof shapes[shape.attrs.id] === 'undefined'){
            shapes[shape.attrs.id] = shape;
        }

        // Send the message about the new shape to any followers - theu will create their orn shape in their own canvas
        send(change);

        shape.on('dragmove', function(e){

            msg = {type: 'changePosition', id: shape.id(), position: shape.position()};
            that.processMessage(JSON.stringify(msg));
            e.cancelBubble = true; // otherwise the layer will report a drag to (0, 0) !

        })

        shape.on('transform', function () {

            let msg = {type: "transform", id: shape.id(),  position: shape.position(), rotation: shape.rotation(), size: shape.size(), scaleX: shape.scaleX(), scaleY: shape.scaleY()};
            send(msg);
        });
 
        return shape;
    }

    // otherwise we exepect the shape to exist.

    shape = shapes[change.id];
    if (!shape){
        trace("Shape not found " + change.id);
        return;
    }

    switch (change.type){
       
        case "changeParent": // an existing shape is changing parent container - like from layer A to layer B.

            // Special case for adding to stage
            if (change.parentType === "Stage"){
                stage.add(shape)
            }
            else {
                let parentContainer = shapes[change.parentId]; // get the Kona shape that is to be the new container.
                parentContainer.add(shape);  // execute the Konva command to switch parents.
            }
            send(change)
            break; 

        case "changeNodes":

            let nodes = [];
            for (let i = 0; i < change.nodes.length; i++){
                let nodeShape = shapes[change.nodes[i]];
                if (nodeShape){
                    nodes.push(nodeShape);
                }
            }

            shape.nodes(nodes);          
            
            send(change)

            break;

        case "styleChange":
 
            applyIfSet('fill', change, shape);
            applyIfSet('stroke', change, shape);
            applyIfSet('strokeWidth', change, shape);
            applyIfSet('strokeEnabled', change, shape);

            send(change)
            break;         
                
        case "changePosition":  
                
            shape.position(change.position);          
            send(change);
            break;         
    
        case "transform":
 
            applyIfSet('position', change, shape);
            applyIfSet('size', change, shape);
            applyIfSet('rotation', change, shape);
            applyIfSet('scaleX', change, shape);
            applyIfSet('scaleY', change, shape);
            
            send(change);

            break;


        case "changeAttr":  // an attribute of a shape has changed - mirror the change in this follower.

            if ('src' in change){
                applyIfSet('visible', change, shape); 
            }
            
            send(change);

            break;  

        case "nominateSelectionRect":

            this.selectionRectangle = shape;

            send(change);

            break;

        case "wireUpTransforming":

            //shape = layer.
            setupTransforming(shape)

            send(change);

            break;

        default: 
            trace("Unexpected type '" + change.type + " for shape id  " + change.id);
            break;
    }
  }
} // end of the syncHandler object declaration.


/**
 * This is the PeerJS wrapper.
 * 
 */
 function peerCom(){
    
    var lastPeerId = null,   
        peer = null,
        conn = null;

    this.connections = {};
    
    this.peer = null; // external link to peer object
    
    let that =  this; // Because what exactly is 'this' in the async calls ? Better to be sure !

    // Create our own peer object with connection to shared PeerJS server
    let initialize = function() {
        console.log('initialize start')
        peer = new Peer(null, {
            debug: 2 // print error and warnings
        });
        this.peer = peer;

        /**
        * Create the peer connection with the server. 
        */      
        peer.on('open', function (id) {
            // Workaround for peer.reconnect deleting previous id
            if (peer.id === null) {
            peer.id = lastPeerId;
            } else {
            lastPeerId = peer.id;
            }
            myEventBus.emit('peerOpen', {eventId: 'peerOpen', peerId: peer.id});
        });

        /**
         *  When another peer opens a connection to this peer. 
         */
        peer.on('connection', function (c) {
            c.on('open', function() {
            conn = c;
            
            that.configureConnection(conn); 

            trace('trigger incomingConnectionOpened id ' + conn.peer);

            myEventBus.emit('incomingConnectionOpened', {eventId: 'incomingConnectionOpened', peerId: conn.peer});        
            });
        });
      
        peer.on('disconnected', function () {
            peer.id = lastPeerId;
            peer._lastServerId = lastPeerId;
            peer.reconnect();
        });

        peer.on('close', function() {
            conn = null;
        });

        peer.on('error', function (err) {
            console.log(err);
        });

        peer.connect();
    };
    
    /**
     * Configure a connection - whether incoming from another peer or outgoing from this peer.
     */
    this.configureConnection = function(conn){
        
        that.connections[conn.peer] = conn; // PeerJS docs suggest managing your own list of connections.
        
        // Handle incoming data
        conn.on('data', function (data) {

            trace('Received ' + data);
            myEventBus.emit('connectionData', {eventId: 'connectionData', msg:data}); 

        });

        conn.on('close', function () {

            delete that.connections[conn.peer];
            trace('connectionDiconnected id ' + conn.peer)
            myEventBus.emit('connectionDiconnected', {eventId: 'connectionDiconnected', msg:conn.peer}); 

        });

        conn.on('error', function (err) {

            trace('connectionError id ' + JSON.stringify(err))
            myEventBus.emit('connectionError', {eventId: 'connectionError', error:err}); 

        });
    }


    /**
    * Create the connection between the two Peers.
    */
    this.join = function (targetPeerId) {

      // Create connection to destination peer specified in the input field
      conn = peer.connect(targetPeerId, {
        reliable: true
      });

      that.configureConnection(conn);

      //Opening a connection to a peer - note a peer can have multiple connections!
      conn.on('open', function () {
 
        that.connections[conn.peer] = conn; // store the connection in the hash.
        trace('trigger outgoingConnectionOpened id ' + conn.peer)
        myEventBus.emit('outgoingConnectionOpened', {eventId: 'outgoingConnectionOpened', peerId:conn.peer});  

      });

 
    };  

    // 
    /**
     *  Send message to all connections
     */
    this.sendMessage = function(msg, originalSource){
              
        msg.src = peer.id;
        msg.cnt = traceCnt;
        let jsonMsg = JSON.stringify(msg);
        for (const [key, conn] of Object.entries(that.connections)) {
            if (key !== originalSource){ // do not send to connection that was the originator of the message

                if (conn && conn.open) {

                    trace('Sending: ' + JSON.stringify(msg))
                    conn.send(jsonMsg)
                    
                } else {

                    console.log('Connection is closed: ', conn);
                }
            }
        }
    }
 
    initialize();
  } // end of the peerCom object declaration



// a simple trace output function so we can see some of what is happening - better than console.log!
let traceCnt = 0,
    traceEle = $('#trace');
function trace(msg){
    if ($('#traceOn').is(':checked')){
        traceCnt++;
        traceEle.prepend('<pre>' + msg + ' </pre>'); 
    }
}


//
// This is the start of the code    
//

console.clear();

// Create the sync handler object.
let handler = new syncHandler();

// Making the stage is standard Konva API code.
let stage = new Konva.Stage({container: "container", width: $('#container').width(),  height: $('#container').height()});


// when the peer connects to the server we set the visible peer id value element.
myEventBus.on('peerOpen', function(detail){
    $('#peerId').val(detail.detail.peerId);
}); 
handler.connect(); // connect to the peer connection exchange - this is async - raises the peerOpen bus event

// While the connection is being prepared async, tell the handler about our local stage.
handler.setStage(stage); 
    
// Add a couple of listeners for connection events, to show a list of connections to/from this peer 
myEventBus.on('incomingConnectionOpened', function(detail){ 
    handler.role = "follower";
    displayConnections();
});    
myEventBus.on('outgoingConnectionOpened', function(detail){ 
    handler.role = "presenter";
    displayConnections();});    
myEventBus.on('connectionDiconnected', function(detail){ displayConnections();});    
 
// Simple func to display the peer connection data in the UI. 
function displayConnections(){

    let s = [];
    for (const [key, conn] of Object.entries(handler.peerObj.connections)) {
        s.push(key);
    }

    $('#connections').val(s);  
    $('#role').html(handler.role);  

} 

// Add a listener to be fired when change messages are received.
myEventBus.on('connectionData', function(messageObj){ 

    handler.processMessage(messageObj.detail.msg);

});  

/* from here onwards is Konva canvas admin code */     

// the Konva admin code has to be wrapped in a function because we need to wait for the connection to the other browser to be done before we start drawing 
function setupCanvas(){

    // The stage object was made via standard Konva API but for all other containers and shapes we use the handler 
    // function which adds the id and wires up listener on attrs list.

    // Make a layer
    let msg = {type: 'makeShape', typeName: 'Layer', attrs: { draggable: true}};
    let layer = handler.processMessage(JSON.stringify(msg));

    // Add the layer to the stage. 
    msg = {type: 'changeParent', id: layer.id(), parentId: stage.id(), parentType: stage.getType()};
    handler.processMessage(JSON.stringify(msg));

    // Make a rect.
    msg = {type: 'makeShape', typeName: 'Rect', attrs: {name: 'r1', x: 20, y: 10, width: 100, height: 80, fill: 'cyan', draggable: true}};
    let rect1 = handler.processMessage(JSON.stringify(msg));

    // Add rect1 to layer via syncHandler
    msg = {type: 'changeParent', id: rect1.id(), parentId: layer.id(), parentType: layer.getType()};
    handler.processMessage(JSON.stringify(msg));

    // Make a circle
    msg = {type: 'makeShape', typeName: 'Circle', attrs: {name: "c1", x: 140, y: 100, radius: 40, fill: 'magenta', draggable: true}};
    let circle1 = handler.processMessage(JSON.stringify(msg));

    // Add circle1 to layer via syncHandler
    msg = {type: 'changeParent', id: circle1.id(), parentId: layer.id(), parentType: layer.getType()};
    handler.processMessage(JSON.stringify(msg));

    // Make a pentagon - a mor involved shape...
    msg = {type: 'makeShape', typeName: 'RegularPolygon', attrs: {name: 'poly', 
                x: 500,
                y: stage.height() / 2,
                sides: 5,
                radius: 70,
                fillRadialGradientStartPoint: { x: 0, y: 0 },
                fillRadialGradientStartRadius: 0,
                fillRadialGradientEndPoint: { x: 0, y: 0 },
                fillRadialGradientEndRadius: 70,
                fillRadialGradientColorStops: [0, 'red', 0.5, 'yellow', 1, 'blue'],
                stroke: 'black',
                strokeWidth: 4,
                draggable: true,
            }};
    let radialGradPentagon1 = handler.processMessage(JSON.stringify(msg));


    // Add radialGradPentagon1 to layer via syncHandler
    msg = {type: 'changeParent', id: radialGradPentagon1.id(), parentId: layer.id(), parentType: layer.getType()};
    handler.processMessage(JSON.stringify(msg));

    //
    // Now we carry out a handful of attribute changes on the shapes to confirm the messages work as anticipated !
    //

    // Move the rect to x = 101 
    msg = {type: 'changePosition', id: rect1.id(), position: {x: 101, y: rect1.y()}};
    handler.processMessage(JSON.stringify(msg));


    // Fill rect with red and rotate 45 degrees.
    msg = {type: "styleChange", id: rect1.id(),  fill: 'red', stroke: 'lime', strokeWidth: 4};
    handler.processMessage(JSON.stringify(msg));

    msg = {type: "transform", id: rect1.id(), rotation: 45};
    handler.processMessage(JSON.stringify(msg));

    // make circle draggable
    circle1.draggable(true);

    // Change the pentagon gradient
    radialGradPentagon1
        .fillRadialGradientEndRadius(60)
        .fillRadialGradientColorStops([0, 'red', 0.5, 'yellow', 1, 'blue']);

    // We will also now make a transformer on Stage 1 to experiment with dynamic attr changes.
    msg = {type: 'makeShape', typeName: 'Transformer', attrs: {name: 'tr', 
        anchorStroke: 'red',
        anchorFill: 'yellow',
        anchorSize: 20,
        borderStroke: 'green',
        borderDash: [3, 3],
        nodes: [],
    }};

    var tr = handler.processMessage(JSON.stringify(msg));
    handler.transformer = tr;
    msg = {type: 'changeParent', id: tr.id(), parentId: layer.id(), parentType: layer.getType()};
    handler.processMessage(JSON.stringify(msg));

    // add a new rect to be used as a mouse-selection rectangle via click & drag on stage.
    msg = {type: 'makeShape', typeName: 'Rect', attrs: {name: 'selectionRect',
        fill: 'rgba(0,0,255,0.5)',
        visible: false
    }};
    var selectionRectangle = handler.processMessage(JSON.stringify(msg));
    msg = {type: 'changeParent', id: selectionRectangle.id(), parentId: layer.id(), parentType: layer.getType()};
    handler.processMessage(JSON.stringify(msg));          

    msg = {type: 'nominateSelectionRect', id : selectionRectangle.id()};
    handler.processMessage(JSON.stringify(msg));  

    msg = {type: 'wireUpTransforming', id : layer.id()};
    handler.processMessage(JSON.stringify(msg));  

}

function setupTransforming(layer){
    
   trace(JSON.stringify({type: "info", info: "selectionRectId: " + handler.selectionRectangle.id()}));

    // Following copied from https://konvajs.org/docs/select_and_transform/Basic_demo.html
    // Add selectionRectangle to layer via syncHandler
    let x1, y1, x2, y2;
    stage.on('mousedown touchstart', (e) => {
        // do nothing if we mousedown on any shape

        if (e.target !== stage) {
            return;
        }
        x1 = stage.getPointerPosition().x;
        y1 = stage.getPointerPosition().y;
        x2 = stage.getPointerPosition().x;
        y2 = stage.getPointerPosition().y;

        handler.selectionRectangle.visible(true);
        trace("** Select rect visible true !")
        let msg = {type: "transform", id: handler.selectionRectangle.id(), size: {width: 0, height: 0}};
        handler.processMessage(JSON.stringify(msg));

    });

    stage.on('mousemove touchmove', () => {
        // do nothing if we didn't start selection
        if (!handler.selectionRectangle.visible() ) {
        return;
        } 

        x2 = stage.getPointerPosition().x;
        y2 = stage.getPointerPosition().y;

        let msg = {type: "transform", id: handler.selectionRectangle.id(), position: {x: Math.min(x1, x2), y: Math.min(y1, y2)}, size: {width: Math.abs(x2 - x1), height: Math.abs(y2 - y1)}};
        handler.processMessage(JSON.stringify(msg));

    });

    stage.on('mouseup touchend', () => {

        // no nothing if we didn't start selection
        if (!handler.selectionRectangle.visible()) {
        return;
        }
        // update visibility in timeout, so we can check it in click event
        setTimeout(() => {
            handler.selectionRectangle.visible(false);
        });


        var shapes = stage.find();

        
        var shapes = layer.getChildren(function(node){
            return node.name() !== 'selectionRect' && node.getClassName() != "Transformer";
        });

        var box = handler.selectionRectangle.getClientRect();
        var selected = shapes.filter((shape) =>
                                    Konva.Util.haveIntersection(box, shape.getClientRect())
                                    );

        let nodeIdList = [];
        for (let i = 0; i < selected.length; i++){
            nodeIdList.push(selected[i].id());
        }                                

        msg = {type: 'changeNodes', id: handler.transformer.id(), nodes: nodeIdList};
        handler.processMessage(JSON.stringify(msg));

    });
}


// button to start drawing.
$('#draw').on('click', function(){
    setupCanvas()
});

// Buttons to connect between peers.
$('#join').on('click', function(){
  let targetPeerId = $('#newConnectionId').val();
  console.log("Peer joining peer " + targetPeerId);
  handler.join(targetPeerId);
  $('#newConnectionId').val("");
}) 
 
// Buttons to clear trace.
$('#clear').on('click', function(){
    traceEle.html('');
}) 
 
    
})

</script>
</html>

Summary

Thanks for sticking with me this far – it’s been a fairly complex subject but worthwhile. We’ve seen that the original proxy approach was not viable because of the volume of messages generated, and switched to a more compressed form of messaging by combining changes – something that would probably ultimately lead to a full messaging API wrapping the Konva API and including the peerjs communications.

But even this rough & ready hack has shown that the concept of a message-based cross browser sync process IS possible without the need for a complex server in the center.

Thanks for reading.

VW Aug 2021

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: