Konva – an approach to drag-bounds control for a group

Stuck on how to keep the contents of a group confined within a rect – lets take a look.

TLDR: take a look at the demo and code here at codepen.

We have a stage with 2 layers. Layer1 is not draggable and it contains a rect the same size as the stage with a transparent fill and a red stroke. This rect shows us where the stage ‘is’. Ok – for all practical purposes, the stage extends infinitely in all directions. But we need some way to visualize when the stage is dragged (you do know the stage is draggable – right?). Anyway, if you drag the stage the fixed layer1 and its fixed stageRect go with it to show the movement.

Meanwhile there is a second layer which IS draggable. This layer has a similar rect demarking its boundaries – same size as the initial stage, transparent fill, blue border.

On this layer we have a fixed rect filled with blue. This is going to be used to constrain the pink rect when we drag it.

Here comes a subtle point – there’s a group on the same layer as the blue rect. Inside that group, as its child shapes, there is a pink rect and a text shape. The pink rect is there to give us something to visualize the group. The text is there for info. In reality what we are about to do would work for any set of shapes in the group – I just kept it simple.

Each of the filled & unfilled rects have a display of their (x, y) position, as does the group. This is garnered from the plain shape.position() method which returns the position of the shape in relation to the parent container.

The stage position is relative to the topleft corner of the HTML canvas element that hosts it.

The layers position is relative to the stage origin.

The blue rects position is relative to the layer that it is on.

The group’s position is relative to the layer it sits on – the same layer as the blue rect sits on.

The pink rect’s position is relative to the group’s origin.

Here’s a look.

At this stage you might want to go ahead and drag the stage, layer, and group rect around a bit and watch the text displays. When a value does not change that is because whatever you did had no affect on it specifically. For example, dragging the stage has no affect on the layer.position() for the layer – this is because, as I said, the position of each shape is relative to its parent. Moving the parent does not affect the relative position of its children.

All very sensible and how could it work any other way – it would be a nightmare!

The faulty group position assumption

Here’s a point many people miss – when a group is added to a layer, just like a shape, unless you specify x & y values, it will be placed at (x: 0, y: 0). And critically important – adding shapes to the group WILL NOT change the group’s position. What – huh? That’s right, the group does NOT reconfigure itself to wrap around its child shapes. But you generally DO need to know that wrap-around size and position – for that use group.getClientRect().

In the default mode, with no configuration parameters, what getClientRect returns as its position (x, y) is the absolute position – not the position in relation to its containing parent group, or layer, or the stage. This value is relative to the top corner of the HTML5 canvas element that hosts Konva.

If you want a bit more detail on the Konva.Group hop over to my other article Konva – the Group, useful but spooky then come back.

The next assumption trap – for a group clientRect is not selfRect

I even made this mistake and I’m meant to know this stuff! For a standard Konva.Rect, when we think about what getClientRect() does it pretty much matches what getSelfRect() does – both give us a rect structure describing the rect we see on screen, although in absolute v’s relative coordinate systems.

But think now about the case of a Konva.Group. Remember assumption no #1 – the group is located at (0, 0) but getClientRect does NOT give (0,0) as the top-left corner because there is no visible output there, unless you happen to have some child shape in the group located at (0, 0). So when you call group.getClientRect(), the top-left of the result it NOT the same as the group.position(). To visuals that look at the demo – the lime green bordered rect shows us the area covered by the group – look at all that empty space left and above the pink rect. Hmmm – do you ‘see’ that in your head when you visualize the group? Maybe not.

Ok – we’ve covered the foundation info we need to cover the drag bounds logic but lets just talk through the objective to be clear.

The mission

The mission is to keep the contents of the group inside the blue filled rect. A use-case for this is annotating / drawing markups on an image. The image would be the big blue rect and the annotations would be arrows, text, shapes, probably in a group.

Notice the stress on the contents of the group. This is subtle but it underlines why the more normal case of keeping one Konva.Rect inside another fails for Konva.Groups. That’s because of the unseen empty space discussed above.

So, if we think of this task as keeping one rectangle inside the bounds of another larger one, then we can use the blue rect and the rect surrounding the group’s child shapes as the two rects.

The math / logic to check for one-rect-inside-another is very simple and boils down to the is-point-in-rect test for the four corners of the inner rect being checked as inside the outer rect. Simples.

Here are those two functions. You create plain JS rect objects from the position and size of the rects you get from calling getClientRect() on the outer Konva.Rect and the Komva.Group.

// Check rect r2 is completely inside bounds of r1
// Each rect should be of form {left: , top: , width: , height}
function rectInRect(r1, r2){
  return ptInRect({x: r2.left, y: r2.top}, r1) &&  ptInRect({x: r2.right, y: r2.top}, r1) &&  ptInRect({x: r2.right, y: r2.bottom}, r1) &&  ptInRect({x: r2.left, y: r2.bottom}, r1);
}

// Check point is inside rect
function ptInRect(pt, rect){
      if ( (pt.x >= rect.left) && (pt.x <= rect.right) && (pt.y >= rect.top) && (pt.y <= rect.bottom)){ 
      return true;
    } 
    return false;
}

And here’s how I made the bounds check process work. Just for fun I used a standard dragMove event rather than a dragBoundFunc. There’s nothing particularly clever here – we make the two plain JS rect structures and throw them into the rect-in-rect function. If we get an OK back then we allow the current position to be used and not it down, but if the response tells us the position would be outside the outer rect then we reset the position of the group to the last good position. Thats it.

let lastGoodPos = {x: 0, y: 0};
group.on('dragstart', function(){
  lastGoodPos = group.absolutePosition();
})
group.on('dragmove', function(){
  const pos = stage.getPointerPosition(),
        containingBox = rect.getClientRect(), 
        r1 = { 
          left: containingBox.x,
          top: containingBox.y,
          right: containingBox.x + containingBox.width,
          bottom: containingBox.y + containingBox.height
        }
        movingBox = group.getClientRect(),
        r2 = { 
          left: movingBox.x,
          top: movingBox.y,
          right: movingBox.x + movingBox.width,
          bottom: movingBox.y + movingBox.height
        },
        isContained =  rectInRect(r1, r2);
  
  if (isContained){
    lastGoodPos = group.absolutePosition();
    groupRect.fill('magenta');
  }
  else {
    this.absolutePosition(lastGoodPos);
    groupRect.fill('red');
  }
  
})

Summary

We’ve looked at visualizing some of what goes on when we drag the stage and layers around, and worked up a way to handle containing a group in a rect, so mission accomplished!

Along the way we looked at a couple of foundation assumption faults around Konva.Groups.

Thanks for reading.

VW Nov 22

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 )

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: