<script src="https://unpkg.com/konva@8/konva.min.js"></script>
<div id='container1' class='container'>
</div>
<div id='container2' class='container'>
</div>
  <p id='trace'></p>
.container {
  width: 800px;
  height: 300px;
  background-color: silver;
  margin: 5px;
}
#trace {
  max-height: 200px;
  overflow-y: scroll;
  font-family: 'Courier'
}
pre {
  margin: 0;
}
let traceEle = $('#trace');

/*
 * SyncHandler is a DIY object for synchronising canvases. In this case we have one leader and one follower. 
 * An object is used to scope the code. Create new instaces via
 * let myStage = new Konva.Stage(..all usual Konva stage stuff...)
 * let myHandler = new syncHandler(myStage, 'h4'); // where h4 = a useful trace prefix so we can see which handler is fired.
 */
function syncHandler(stage, handlerName){
  
  let followers = [],     // In this demo we have only one listening canvas but we will assume there could be many.
      shapes = {};        // Each shape created is placed in this object thus forming a quick lookup list based on id.
  
  // 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());
  }


   // Fires during init stage - set the stage id if not set already.
  stage.id(getGuid());

  
  // Add a follower to the list of followers.
  this.addFollower = function(newFollower){
    followers.push(newFollower);
  }

  
  // Func to make a proxy for the shape's attrs arry.
  function makeProxy(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) {

          changeAttr(prop, value, target); // Invoke the function to communicate the change to any follower[]

          // finally let the target - the shape's attr array get the change. 
          return Reflect.set(array, prop, value, target);

        },
        deleteProperty(array, prop, target) {
          // Included in case needed in future.
          let msg = {type: 'attrdel', id: target["id"], name: prop};
          sendMessage(msg);      

          // finally let the target - the shape's attr array get the change. 
          return Reflect.deleteProperty(array, prop);
        }
      })
    return prxy;
  }
  
  
  // This func is a wrapper for the 'new Konver.<ShapeName>()' process. It is required to:
  // - ensure that the new shape will have an id;
  // - hide the wiring up of the shape.attrs prox;
  // - add the shape into our shapes list, keyed on the assigned id;
  // - send the message about the new object to the follower[] canvases.
  this.makeShape = function(typeName, attrs){
    let shape = null;
 
    attrs = (typeof attrs == 'undefined' ? {} : attrs); // if attrs is not supplied then make an empty array
    attrs.id = (typeof attrs.id === 'undefined' ? getGuid() : attrs.id); // ensure there is an ID.
    
    shape = new Konva[typeName](); // Make the actual Konva shape in this canvas.

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

    // Send the message about the new shape to any follower[]
    let msg = {type: 'makeShape', name: typeName, attrs: attrs };
    sendMessage(msg);
    
    return shape; // Hand back the shape to the code that created it.

  }  
  
  // This func is a wrapper for Konva.container.add() or move(). It takes 
  // as args the shape being moved and the container that is to be the new parent.
  this.changeParent = function(shapeObj, newParentObj){

    newParentObj.add(shapeObj); // add shape to new parent.

    // Send the message about the new shape to any follower[]
    let msg = {type: 'changeParent', id: shapeObj.id(), parentId: newParentObj.id(), parentType: newParentObj.getType()};
    sendMessage(msg);
  }
 
  /* this func is a wrapper for the Konva.shape.setAttr() method. 
   * Network comms are costly - we do not want to send messages about propos that either have not changed or 
   * where the change to a numeric property is insignificant. 
  */
  function changeAttr(prop, value, target){

    let currentVal = target[prop],
        sendIt = true; // flag to indicate change is to be sent - overridden below for numeric types if needed.

    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"],  name: prop, value: value };
        sendMessage(msg);
      }
    }
    return true;
  }
  
  // Func to convert the given message to a JSON string and send it to any followers.
  function sendMessage(msg){
  
    if (followers.length === 0){ // no send if no listening followers.
      return;
    }
    let jsonMsg = JSON.stringify(msg);
    for (let i = 0; i < followers.length; i++){
      followers[i].processMessage(jsonMsg);
    }
  }
  
  /* In this func we process a received change message. In this demo this is simply one object calling a func in another
   * but in the final version this will be talking via peer-to-peer between browsers. We receive a message in JSON format 
   * containing the change information. The 'type' value gives either 'makeShape', 'changeParent', or 'changeAttr'.
   *
   * Note that when this runs it is within the context of a 'following' syncHandler instance, not the sending instance!
   * 
  */
  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 = null;

    switch (change.type){

      case "makeShape": // a new shape message.        
        shape = this.makeShape(change.name, change.attrs);  // make the shape in the follower syncHandler - this works 
                                                            // in this demo because the follwower has no followers of its own.
                                                            // If it _did_ have followers a deadlock would occur !

        trace(handlerName + ".makeShape: " + change.name + ' ' +  shape.id()); // record a trace of what is going on       
        
        shapes[shape.id()] = shape;  // note the shape in our shape list.
        
        break;
        
      case "changeParent": // an existing shape is changing parent container - like from layer A to layer B.

        trace(handlerName + '.changeParent: id=' + change["id"])
       
        shape = shapes[change.id]; // get the Konva shape instance that is moving parent

        // 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.
        }
        break; 
        
      case "changeAttr":  // an attribute of a shape has changed - mirror the change in this follower.
        
        trace(handlerName + '.changeAttr: id=' + change["id"] + ' - ' + change.name + ' = ' + change.value);
        
        shape = shapes[change.id];
        shape.setAttr(change.name, change.value);          
        break;  
    }
  }
  

  
} // end of the syncHandlerobject declaration.

// a simple trace output function so we can see some of what is happening - better than console.log!
function trace(msg){
  traceEle.prepend('<pre>' + msg + ' </pre>'); 
}


/* from here onwards is Konva canvas admin */

// Making the stage is standard Konva API code.
let stage1 = new Konva.Stage({container: "container1", width: $('#container1').width(),  height: $('#container1').height()});
// And now we create the handler object.
let handler1 = new syncHandler(stage1, 'sh1');

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

// Very importantly - we inform handler1 than handler2 is listening and wants to know about changes.
handler1.addFollower(handler2);

// The stage object was made via standard Konva API but for all other containers and shapes we use the handler 
// function which adds id and wires up listener on attrs list.
let layer1 = handler1.makeShape("Layer");  

// Add the layer to the stage. 
// Adding a shape is done via syncHandler so that we can capture and broadcast the change
handler1.changeParent(layer1, stage1);

// Make a rect.
let rect1 = handler1.makeShape("Rect", {x: 20, y: 10, width: 100, height: 80, fill: 'cyan', draggable: true});
// Add rect1 to layer1 via syncHandler
handler1.changeParent(rect1, layer1);

// Make a circle
let circle1 = handler1.makeShape("Circle", {x: 140, y: 100, radius: 40, fill: 'magenta'});
// Add circle1 to layer1 via syncHandler
handler1.changeParent(circle1, layer1);

// Make a pentagon
let radialGradPentagon1 = handler1.makeShape("RegularPolygon", {
          x: 500,
          y: stage1.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,
        });
// Add radialGradPentagon1 to layer1 via syncHandler
handler1.changeParent(radialGradPentagon1, layer1);

//
// Now we carry out a handful of attribute changes on the shapes to confirm it works !
//

// Move the rect to x = 101 
rect1.x(101)

// Fill rect with red and rotate 45 degrees.
rect1
  .fill('red')
  .rotation(45);

rect1.on('mousedown', function(e){
  e.cancelBubble = true;
})
// 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.
var tr = handler1.makeShape("Transformer", {
  anchorStroke: 'red',
  anchorFill: 'yellow',
  anchorSize: 20,
  borderStroke: 'green',
  borderDash: [3, 3],
  nodes: [],
});
handler1.changeParent(tr, layer1);

// attach the transformer to the rect
tr.nodes([rect1]) 


layer1.on('mousedown', function(){
  traceEle.html('');
})

// add a new rect to be used as a mouse-selection rectangle via click & drag on stage1.
var selectionRectangle = handler1.makeShape("Rect", {
  name: 'selectionRect',
  fill: 'rgba(0,0,255,0.5)',
  visible: false,
});

// Following copied from https://konvajs.org/docs/select_and_transform/Basic_demo.html
// Add selectionRectangle to layer1 via syncHandler
handler1.changeParent(selectionRectangle, layer1);
let x1, y1, x2, y2;
stage1.on('mousedown touchstart', (e) => {
  // do nothing if we mousedown on any shape
 
  if (e.target !== stage1) {
    return;
  }
  x1 = stage1.getPointerPosition().x;
  y1 = stage1.getPointerPosition().y;
  x2 = stage1.getPointerPosition().x;
  y2 = stage1.getPointerPosition().y;

  selectionRectangle.visible(true);
  selectionRectangle.width(0);
  selectionRectangle.height(0);
});

stage1.on('mousemove touchmove', () => {
  // do nothing if we didn't start selection
  if (!selectionRectangle.visible() ) {
    
    return;
  } 
  
  x2 = stage1.getPointerPosition().x;
  y2 = stage1.getPointerPosition().y;

  selectionRectangle.setAttrs({
    x: Math.min(x1, x2),
    y: Math.min(y1, y2),
    width: Math.abs(x2 - x1),
    height: Math.abs(y2 - y1),
  });

});
stage1.on('mouseup touchend', () => {

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


  var shapes = stage1.find();
 
  var shapes = layer1.getChildren(function(node){
     return node.name() !== 'selectionRect' && node.getClassName() != "Transformer";
  });
    
  var box = selectionRectangle.getClientRect();
  var selected = shapes.filter((shape) =>
                               Konva.Util.haveIntersection(box, shape.getClientRect())
                              );
  tr.nodes(selected);
 
});
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js