4

I have two objects a parent (red) and a child (blue). The parent object is fixed and can't be moved, only the child object is movable and the child is always bigger than the parent. In whatever way the child object is moved it should always be contained inside the child, which means we should never see the red rectangle.

enter image description here

Demo: https://codesandbox.io/s/force-contain-of-object-inside-another-object-fabric-js-7nt7q

I know there are solutions to contain an object within the canvas or other object boundaries (ex. Move object within canvas boundary limit) which mainly force the top/right/bottom/left values to not exceed the parent values, but here we have the case of two rotated objects by the same degree.

I have a real-life scenario when a user uploads a photo to a frame container. The photo is normally always bigger than the frame container. The user can move the photo inside the frame, but he should not be allowed to create any empty spaces, the photo should always be contained inside the photo frame.

Adam M.
  • 1,071
  • 1
  • 7
  • 23

2 Answers2

1

I would go with a pure canvas (no fabricjs), do it from scratch that way you understand well the problem you are facing, then if you need it that same logic should be easily portable to any library.

You have some rules:

  • The parent object is fixed and can't be moved,
  • The child object is movable.
  • The child is always bigger than the parent.
  • The child object is always constrained by the parent.

So my idea is to get all four corners, that way on the move we can use those coordinates to determine if it can be moved to the new location or not, the code should be easy to follow, but ask if you have any concerns.

I'm using the ray-casting algorithm:
https://github.com/substack/point-in-polygon/blob/master/index.js
With that, all we need to do is check that the corners of the child are not inside the parent and that the parent is inside the child, that is all.

I'm no expert with FabricJS so my best might not be much...
but below is my attempt to get your code going.

<canvas id="canvas" width="500" height="350"></canvas>

<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/3.6.2/fabric.min.js"></script>
<script>
    var canvas = new fabric.Canvas("canvas");
    canvas.stateful = true;

    function getCoords(rect) {
        var x = rect.left;
        var y = rect.top;
        var angle = (rect.angle * Math.PI) / 180;

        var coords = [{ x, y }];
        x += rect.width * Math.cos(angle);
        y += rect.width * Math.sin(angle);
        coords.push({ x, y });

        angle += Math.PI / 2;
        x += rect.height * Math.cos(angle);
        y += rect.height * Math.sin(angle);
        coords.push({ x, y });

        angle += Math.PI / 2;
        x += rect.width * Math.cos(angle);
        y += rect.width * Math.sin(angle);
        coords.push({ x, y });
        return coords;
    }

    function inside(p, vs) {
        var inside = false;
        for (var i = 0, j = vs.length - 1; i < vs.length; j = i++) {
            var xi = vs[i].x, yi = vs[i].y;
            var xj = vs[j].x, yj = vs[j].y;
            var intersect =
                yi > p.y !== yj > p.y && p.x < ((xj - xi) * (p.y - yi)) / (yj - yi) + xi;
            if (intersect) inside = !inside;
        }
        return inside;
    }

    var parent = new fabric.Rect({
        width: 150, height: 100, left: 200, top: 50, angle: 25, selectable: false, fill: "red"
    });
    var pCoords = getCoords(parent);

    var child = new fabric.Rect({
        width: 250, height: 175, left: 180, top: 10, angle: 25, hasControls: false, fill: "rgba(0,0,255,0.9)"
    });

    canvas.add(parent);
    canvas.add(child);

    canvas.on("object:moving", function (e) {
        var cCoords = getCoords(e.target);
        var inBounds = true;
        cCoords.forEach(c => { if (inside(c, pCoords)) inBounds = false; });
        pCoords.forEach(c => { if (!inside(c, cCoords)) inBounds = false; });
        if (inBounds) {
            e.target.setCoords();
            e.target.saveState();
            e.target.set("fill", "rgba(0,0,255,0.9)");            
        } else {
            e.target.set("fill", "black");
            e.target.animate({
                left: e.target._stateProperties.left,
                top: e.target._stateProperties.top
              },{
                duration: 500,
                onChange: canvas.renderAll.bind(canvas),
                easing: fabric.util.ease["easeInBounce"],
                onComplete: function() {
                  e.target.set("fill", "rgba(0,0,255,0.9)");
                }
            });
        }
    });
</script>

That code is on sandbox as well:
https://codesandbox.io/s/force-contain-of-object-inside-another-object-fabric-js-dnvb5

It certainly is nice not to worry about coding all the click/hold/drag fabric makes that real easy...

I was experimenting with FabricJS and there a nice property of the canvas
(canvas.stateful = true;)
that allows us to keep track of where we've been, and if we go out of bounds we can revert that movement, also playing with animate that gives the user visual feedback that the movement is not allowed.

Here is another version without animation:

<canvas id="canvas" width="500" height="350"></canvas>

<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/3.6.2/fabric.min.js"></script>
<script>
    var canvas = new fabric.Canvas("canvas");
    canvas.stateful = true;

    function getCoords(rect) {
        var x = rect.left;
        var y = rect.top;
        var angle = (rect.angle * Math.PI) / 180;

        var coords = [{ x, y }];
        x += rect.width * Math.cos(angle);
        y += rect.width * Math.sin(angle);
        coords.push({ x, y });

        angle += Math.PI / 2;
        x += rect.height * Math.cos(angle);
        y += rect.height * Math.sin(angle);
        coords.push({ x, y });

        angle += Math.PI / 2;
        x += rect.width * Math.cos(angle);
        y += rect.width * Math.sin(angle);
        coords.push({ x, y });
        return coords;
    }

    function inside(p, vs) {
        var inside = false;
        for (var i = 0, j = vs.length - 1; i < vs.length; j = i++) {
            var xi = vs[i].x, yi = vs[i].y;
            var xj = vs[j].x, yj = vs[j].y;
            var intersect =
                yi > p.y !== yj > p.y && p.x < ((xj - xi) * (p.y - yi)) / (yj - yi) + xi;
            if (intersect) inside = !inside;
        }
        return inside;
    }

    var parent = new fabric.Rect({
        width: 150, height: 100, left: 200, top: 50, angle: 25, selectable: false, fill: "red"
    });
    var pCoords = getCoords(parent);

    var child = new fabric.Rect({
        width: 250, height: 175, left: 180, top: 10, angle: 25, hasControls: false, fill: "rgba(0,0,255,0.9)"
    });

    canvas.add(parent);
    canvas.add(child);

    canvas.on("object:moving", function (e) {
        var cCoords = getCoords(e.target);
        var inBounds = true;
        cCoords.forEach(c => { if (inside(c, pCoords)) inBounds = false; });
        pCoords.forEach(c => { if (!inside(c, cCoords)) inBounds = false; });
        if (inBounds) {
            e.target.setCoords();
            e.target.saveState();
        } else {
            e.target.left = e.target._stateProperties.left;
            e.target.top = e.target._stateProperties.top;
        }
    });
</script>

This algorithm also opens the door for other shapes as well, here is a hexagon version:
https://raw.githack.com/heldersepu/hs-scripts/master/HTML/canvas_contained2.html

Helder Sepulveda
  • 15,500
  • 4
  • 29
  • 56
  • That's is pretty what I was looking for, the update is great as I can now understand that the image is not in the right place, I just need to understand how to force the element to not change the position if it is inside, but I think the only way would be to somehow force it the child element corner to fit that of the parent. – Adam M. Mar 29 '20 at 03:39
  • Yes `stateful` is the way to go! take a look at my last update – Helder Sepulveda Mar 29 '20 at 15:38
  • This is great, it works! I don't need the animation, just simple force the child to not exceed the boundaries of the parent, so I've changed your code to simple set the top-left values to the child to those of the parent. Is there a way to understand which values should I force? Ex. if I drag it from the right to left it should force the right-top values, but how do I get it? https://codesandbox.io/s/force-contain-of-object-inside-another-object-fabric-js-v2-x0m6n. You've deserved the bounty if you can solve this last issue will give it to you straight away. – Adam M. Mar 29 '20 at 19:35
  • I think the `inside` function is perfect, maybe once it returns false it should call another function that returns an array of the names of the exceeding boundaries, ex. `getExceededBoundaries` that returns ex. ['top', 'left'] so I would know to set the child top-left to the values of the parent, otherwise, if it returns only top, then I will know to set the child top value to the value of the parent. – Adam M. Mar 29 '20 at 19:48
  • Yes if you don't want to animate it we remove that block `e.target.animate` and just set the left and top: `e.target.left = e.target._stateProperties.left` _(same for the top)_ that is last known good state for the object being moved ... we don't want to set it to the parent, technically you can, but that will be a visual jump, the user might not understand why... I added a sample snippet – Helder Sepulveda Mar 29 '20 at 20:58
  • It almost perfect, the only issue is that I need it to set to the parent values. If I move quickly the child a few times outside of the parent, the framework will remember the last position that could be inside it. The bounty is already yours, I wish I could give you much more than that, but if you can force it to fit the parent boundaries then it would be great. I don't think it will jump because the execution will be so quick that the user will not even notice that. – Adam M. Mar 30 '20 at 09:04
  • I'm not sure I follow on that jump, in my tests, it is always forced to fit the parent boundaries, the state is only saved when the conditions are met, not possible that it will remember a position outside, that is shown on the second code snippet... this comment thread is getting long, maybe ask a new question with the specific issue and post a link to that new question here and I will follow up. – Helder Sepulveda Mar 30 '20 at 15:17
0

you can just create the new Class, with your object inside, and do all actions only with parent of the children, something like this:

 fabric.RectWithRect = fabric.util.createClass(fabric.Rect, {
      type: 'rectWithRect',
      textOffsetLeft: 0,
      textOffsetTop: 0,
      _prevObjectStacking: null,
      _prevAngle: 0,
      minWidth: 50,
      minHeight: 50,   

      _currentScaleFactorX: 1,
      _currentScaleFactorY: 1,

      _lastLeft: 0,
      _lastTop: 0,

      recalcTextPosition: function () {
           //this.insideRect.setCoords();
           const sin = Math.sin(fabric.util.degreesToRadians(this.angle))
           const cos = Math.cos(fabric.util.degreesToRadians(this.angle))
           const newTop = sin * this.insideRectOffsetLeft + cos * this.insideRectOffsetTop
           const newLeft = cos * this.insideRectOffsetLeft - sin * this.insideRectOffsetTop
           const rectLeftTop = this.getPointByOrigin('left', 'top')
           this.insideRect.set('left', rectLeftTop.x + newLeft)
           this.insideRect.set('top', rectLeftTop.y + newTop)
           this.insideRect.set('width', this.width - 40)
           this.insideRect.set('height', this.height - 40)
           this.insideRect.set('scaleX', this.scaleX)
           this.insideRect.set('scaleY', this.scaleY)
      },

     initialize: function (textOptions, rectOptions) {
       this.callSuper('initialize', rectOptions)

       this.insideRect = new fabric.Rect({
         ...textOptions,
         dirty: false,
         objectCaching: false,
         selectable: false,
         evented: false,
         fragmentType: 'rectWidthRect'
       });
       canvas.bringToFront(this.insideRect);
       this.insideRect.width = this.width - 40;
       this.insideRect.height = this.height - 40;
       this.insideRect.left = this.left + 20;
       this.insideRect.top = this.top + 20;
       this.insideRectOffsetLeft = this.insideRect.left - this.left
       this.insideRectOffsetTop = this.insideRect.top - this.top


       this.on('moving', function(e){
         this.recalcTextPosition();
       })

       this.on('rotating',function(){
         this.insideRect.rotate(this.insideRect.angle + this.angle - this._prevAngle)
         this.recalcTextPosition()
         this._prevAngle = this.angle
       })

       this.on('scaling', function(fEvent){
           this.recalcTextPosition();
       });

       this.on('added', function(){
         this.canvas.add(this.insideRect)
       });

       this.on('removed', function(){
         this.canvas.remove(this.insideRect)
       });

       this.on('mousedown:before', function(){
         this._prevObjectStacking = this.canvas.preserveObjectStacking
         this.canvas.preserveObjectStacking = true
       });

       this.on('deselected', function(){
         this.canvas.preserveObjectStacking = this._prevObjectStacking
       });

     }
 });

and then just add your element to your canvas as usual:

var rectWithRect = new fabric.RectWithRect(
            {
              fill: "red",
            }, // children rect options
           {
             left:100,
             top:100,
             width: 300,
             height: 100,
             dirty: false,
             objectCaching: false,
             strokeWidth: 0,
             fill: 'blue'
           } // parent rect options
      );


canvas.add(rectWithRect);

by the way, you can use method like this to create nested elements, text with background and other.

Codesandbox DEMO

20yco
  • 876
  • 8
  • 28
  • 1
    The solution sets the same position and angle to both elements, but in my case, the parent object is fixed and can't be moved, like I can only move the child, the parent should never move. – Adam M. Mar 26 '20 at 20:17