8

I'm using Fabric.js to draw some rectangles on a canvas. The default behavior is that clicking inside a rectangle selects it. How can I change the behavior such that it is only selected when clicking on the border of the rectangle?

Clicking inside the rectangle but not on the border should do nothing.

You can see this behavior by drawing a rectangle on a TradingView.com chart

It there an option for this in fabric, and if not how could I go around implementing it?

AmerllicA
  • 29,059
  • 15
  • 130
  • 154
parliament
  • 21,544
  • 38
  • 148
  • 238

3 Answers3

4

Fabric.js uses Object.containsPoint() to determine whether a mouse event should target the object. This method, in turn, calculates the object's edges via Object._getImageLines() and checks how many times the projection of a mouse pointer crossed those lines.

The solution below calculates additional inner edges based on the coordinates of each corner, therefore object scale and rotation are taken care of automatically.

const canvas = new fabric.Canvas('c', {
  enableRetinaScaling: true
})

const rect = new fabric.Rect({
  left: 0,
  top: 0,
  width: 100,
  height: 100,
  dragBorderWidth: 15, // this is the custom attribute we've introduced
})

function innerCornerPoint(start, end, offset) {
  // vector length
  const l = start.distanceFrom(end)
  // unit vector
  const uv = new fabric.Point((end.x - start.x) / l, (end.y - start.y) / l)
  // point on the vector at a given offset but no further than side length
  const p = start.add(uv.multiply(Math.min(offset, l)))
  // rotate point
  return fabric.util.rotatePoint(p, start, fabric.util.degreesToRadians(45))
}

rect._getInnerBorderLines = function(c) {
  // the actual offset from outer corner is the length of a hypotenuse of a right triangle with border widths as 2 sides
  const offset = Math.sqrt(2 * (this.dragBorderWidth ** 2))
  // find 4 inner corners as offsets rotated 45 degrees CW
  const newCoords = {
    tl: innerCornerPoint(c.tl, c.tr, offset),
    tr: innerCornerPoint(c.tr, c.br, offset),
    br: innerCornerPoint(c.br, c.bl, offset),
    bl: innerCornerPoint(c.bl, c.tl, offset),
  }
  return this._getImageLines(newCoords)
}

rect.containsPoint = function(point, lines, absolute, calculate) {
  const coords = calculate ? this.calcCoords(absolute) : absolute ? this.aCoords : this.oCoords
  lines = lines || this._getImageLines(coords)
  const innerRectPoints = this._findCrossPoints(point, lines);
  const innerBorderPoints = this._findCrossPoints(point, this._getInnerBorderLines(coords))
  // calculate intersections
  return innerRectPoints === 1 && innerBorderPoints !== 1
}

canvas.add(rect)
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/3.6.2/fabric.min.js"></script>
<canvas id="c" width="400" height="300"></canvas>
shkaper
  • 4,689
  • 1
  • 22
  • 35
  • Thank you for the answer. I wasn't expecting 3 good answers, but I do have to choose one. I'm selecting the other one because it's simpler and involves less math and geometry that is hard for me to understand, even with the comments. – parliament Feb 20 '20 at 00:06
4

This approach overrides the _checkTarget method within FabricJS to reject clicks that are more than a specified distance from the border (defined by the clickableMargin variable).

//sets the width of clickable area
var clickableMargin = 15;

var canvas = new fabric.Canvas("canvas");

canvas.add(new fabric.Rect({
  width: 150,
  height: 150,
  left: 25,
  top: 25,
  fill: 'green',
  strokeWidth: 0
}));

//overrides the _checkTarget method to add check if point is close to the border
fabric.Canvas.prototype._checkTarget = function(pointer, obj, globalPointer) {
  if (obj &&
      obj.visible &&
      obj.evented &&
      this.containsPoint(null, obj, pointer)){
    if ((this.perPixelTargetFind || obj.perPixelTargetFind) && !obj.isEditing) {
      var isTransparent = this.isTargetTransparent(obj, globalPointer.x, globalPointer.y);
      if (!isTransparent) {
        return true;
      }
    }
    else {
     var isInsideBorder = this.isInsideBorder(obj);
     if(!isInsideBorder) {
       return true;
      }
    }
  }
}

fabric.Canvas.prototype.isInsideBorder = function(target) {
   var pointerCoords = target.getLocalPointer();
   if(pointerCoords.x > clickableMargin && 
     pointerCoords.x < target.getScaledWidth() - clickableMargin && 
     pointerCoords.y > clickableMargin && 
     pointerCoords.y < target.getScaledHeight() - clickableMargin) {
     return true;
   }
 }
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/3.6.2/fabric.min.js"></script>
<canvas id="canvas" height="300" width="400"></canvas>
melchiar
  • 2,782
  • 16
  • 27
  • Thank you for the answer. I like your solution more than the other two. Bounty is yours, thanks again – parliament Feb 20 '20 at 00:08
  • I did notice a slight issue though. The box should not be draggable by the center area even if it is selected. Could you please correct that? – parliament Feb 20 '20 at 00:12
  • 1
    Done. Thank you very much! – melchiar Feb 20 '20 at 00:21
  • @melchiar I'm getting this error with your code: `Uncaught TypeError: this.containsPoint is not a function at klass.webpackHotUpdate../src/components/Editor.js.fabric__WEBPACK_IMPORTED_MODULE_1__.fabric.Canvas._checkTarget (Editor.js:26) at klass._searchPossibleTargets (fabric.js:11948) at klass.findTarget (fabric.js:11896) at klass._cacheTransformEventData (fabric.js:13212) at klass.__onMouseMove (fabric.js:13238) at klass._onMouseMove (fabric.js:12811) ` – papryk Aug 12 '21 at 12:16
3

here is my approach, when rect is clicked I am calculating where it is clicked and if it is not clicked on border I have to set canvas.discardActiveObject , see comments on code

var canvas = new fabric.Canvas('c', {
  selection: false
});
var rect = new fabric.Rect({
  left: 50,
  top: 50,
  width: 100,
  height: 100,
  strokeWidth: 10,
  stroke: 'red',
  selectable: false,
  evented: true,
  hasBorders: true,
  lockMovementY: true,
  lockMovementX: true

})
canvas.on("mouse:move", function(e) {
  if (!e.target || e.target.type != 'rect') return;
  // when selected event is fired get the click position.
  var pointer = canvas.getPointer(e.e);
  // calculate the click distance from object to be exact
  var distanceX = pointer.x - rect.left;
  var distanceY = pointer.y - rect.top;

  // check if click distanceX/Y are less than 10 (strokeWidth) or greater than 90 ( rect width = 100)


  if ((distanceX <= rect.strokeWidth || distanceX >= (rect.width - rect.strokeWidth)) || (distanceY <= rect.strokeWidth || distanceY >= (rect.height - rect.strokeWidth))) {
    rect.set({
      hoverCursor: 'move',
      selectable: true,
      lockMovementY: false,
      lockMovementX: false
    });
    document.getElementById('result').innerHTML = 'on border';
  } else {
    canvas.discardActiveObject();
    document.getElementById('result').innerHTML = 'not  on border';
    rect.set({
      hoverCursor: 'default',
      selectable: false,
      lockMovementY: true,
      lockMovementX: true
    });
  }

});

canvas.add(rect);
canvas.renderAll();
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/3.6.2/fabric.min.js"></script>
<div id="result" style="width: 100%; "></div>
<canvas id="c" width="600" height="200"></canvas>
<pre>
</pre>

ps: you can also set the rect property to selectable: false and call canvas.setActiveObject(this); to make it selection inside if statement.

user969068
  • 2,818
  • 5
  • 33
  • 64
  • Thank you for the answer. I wasn't expecting 3 good answers, but I do have to choose one. I'm not selecting yours for 2 reasons: 1) the drag cursor still appears even where not selectable 2) perPixelTargetFind has a negative performance impact – parliament Feb 20 '20 at 00:02
  • @parliament you do not need `perPixelTargetFind`, and mouse can be changed, I have updated fiddle – user969068 Feb 20 '20 at 20:07
  • thanks, another issue is that is should not be draggable from the center area even if it is selected. Please correct that and ill award an additional bounty for the effort – parliament Feb 22 '20 at 18:23
  • You can set lock properties on objects, there are many lock properties available not just draggable, ie. lockMovementY, lockMovementY will lock X/Y movement .see updated fiddle – user969068 Feb 22 '20 at 19:36
  • @parliament let me know if that helps, also in accepted answer when you hold & drag inside rect, it becomes selectable, see https://i.imgur.com/aOdUhfm.gif – user969068 Feb 24 '20 at 23:08