Glory to Russia
Glory to Russia

Reputation: 18712

How to implement canvas panning with Fabric.js

I have a Fabric.js canvas and I want to implement the full-canvas panning that software packages usually do with a "hand" tool. It's when you press one of the mouse buttons, then move over the canvas while holding the mouse button and the visible portion of the canvas changes accordingly.

You can see in this video what I want to achieve.

In order to implement this functionality I wrote the following code:

$(canvas.wrapperEl).on('mousemove', function(evt) {
    if (evt.button == 2) { // 2 is the right mouse button
        canvas.absolutePan({
            x: evt.clientX,
            y: evt.clientY
        });
    }
});

But it doesn't work. You can see in this video what happens.

How can I modify my code in order:

  1. For panning to work like in the first video?

  2. For the event handler to consume the event? It should prevent the context menu from appearing when the user presses or releases the right mouse button.

Upvotes: 20

Views: 16120

Answers (5)

Flavio Silva
Flavio Silva

Reputation: 113

I know this has already been answered, but I redid the pen created in this answer using the new version of fabricjs (4.5.0)

Pen: https://codepen.io/201flaviosilva/pen/GROLbQa

In this case, I used the middle button in the mouse to pan :)

// Enable mouse middle button
canvas.fireMiddleClick = true;

// Mouse Up Event
if (e.button === 2) currentState = STATE_IDLE;

// Mouse Down Event
if (e.button === 2) currentState = STATE_PANNING;

:)

Upvotes: 0

Sabatino
Sabatino

Reputation: 389

I have an example on Github using fabric.js Canvas panning: https://sabatinomasala.github.io/fabric-clipping-demo/

The code responsible for the panning behaviour is the following: https://github.com/SabatinoMasala/fabric-clipping-demo/blob/master/src/classes/Panning.js

It's a simple extension on the fabric.Canvas.prototype, which enables you to toggle 'drag mode' on the canvas as follows:

canvas.toggleDragMode(true); // Start panning
canvas.toggleDragMode(false); // Stop panning

Take a look at the following snippet, documentation is available throughout the code.

const STATE_IDLE = 'idle';
const STATE_PANNING = 'panning';
fabric.Canvas.prototype.toggleDragMode = function(dragMode) {
  // Remember the previous X and Y coordinates for delta calculations
  let lastClientX;
  let lastClientY;
  // Keep track of the state
  let state = STATE_IDLE;
  // We're entering dragmode
  if (dragMode) {
    // Discard any active object
    this.discardActiveObject();
    // Set the cursor to 'move'
    this.defaultCursor = 'move';
    // Loop over all objects and disable events / selectable. We remember its value in a temp variable stored on each object
    this.forEachObject(function(object) {
      object.prevEvented = object.evented;
      object.prevSelectable = object.selectable;
      object.evented = false;
      object.selectable = false;
    });
    // Remove selection ability on the canvas
    this.selection = false;
    // When MouseUp fires, we set the state to idle
    this.on('mouse:up', function(e) {
      state = STATE_IDLE;
    });
    // When MouseDown fires, we set the state to panning
    this.on('mouse:down', (e) => {
      state = STATE_PANNING;
      lastClientX = e.e.clientX;
      lastClientY = e.e.clientY;
    });
    // When the mouse moves, and we're panning (mouse down), we continue
    this.on('mouse:move', (e) => {
      if (state === STATE_PANNING && e && e.e) {
        // let delta = new fabric.Point(e.e.movementX, e.e.movementY); // No Safari support for movementX and movementY
        // For cross-browser compatibility, I had to manually keep track of the delta

        // Calculate deltas
        let deltaX = 0;
        let deltaY = 0;
        if (lastClientX) {
          deltaX = e.e.clientX - lastClientX;
        }
        if (lastClientY) {
          deltaY = e.e.clientY - lastClientY;
        }
        // Update the last X and Y values
        lastClientX = e.e.clientX;
        lastClientY = e.e.clientY;

        let delta = new fabric.Point(deltaX, deltaY);
        this.relativePan(delta);
        this.trigger('moved');
      }
    });
  } else {
    // When we exit dragmode, we restore the previous values on all objects
    this.forEachObject(function(object) {
      object.evented = (object.prevEvented !== undefined) ? object.prevEvented : object.evented;
      object.selectable = (object.prevSelectable !== undefined) ? object.prevSelectable : object.selectable;
    });
    // Reset the cursor
    this.defaultCursor = 'default';
    // Remove the event listeners
    this.off('mouse:up');
    this.off('mouse:down');
    this.off('mouse:move');
    // Restore selection ability on the canvas
    this.selection = true;
  }
};

// Create the canvas

let canvas = new fabric.Canvas('fabric')
canvas.backgroundColor = '#f1f1f1';

// Add a couple of rects

let rect = new fabric.Rect({
  width: 100,
  height: 100,
  fill: '#f00'
});
canvas.add(rect)

rect = new fabric.Rect({
  width: 200,
  height: 200,
  top: 200,
  left: 200,
  fill: '#f00'
});
canvas.add(rect)

// Handle dragmode change

let dragMode = false;
$('#dragmode').change(_ => {
  dragMode = !dragMode;
  canvas.toggleDragMode(dragMode);
});
<div>
  <label for="dragmode">
    Enable panning
    <input type="checkbox" id="dragmode" name="dragmode" />
  </label>
</div>
<canvas width="300" height="300" id="fabric"></canvas>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/1.7.15/fabric.min.js"></script>

Upvotes: 6

Michael Laszlo
Michael Laszlo

Reputation: 12239

An easy way to pan a Fabric canvas in response to mouse movement is to calculate the cursor displacement between mouse events and pass it to relativePan.

Observe how we can use the screenX and screenY properties of the previous mouse event to calculate the relative position of the current mouse event:

function startPan(event) {
  if (event.button != 2) {
    return;
  }
  var x0 = event.screenX,
      y0 = event.screenY;
  function continuePan(event) {
    var x = event.screenX,
        y = event.screenY;
    fc.relativePan({ x: x - x0, y: y - y0 });
    x0 = x;
    y0 = y;
  }
  function stopPan(event) {
    $(window).off('mousemove', continuePan);
    $(window).off('mouseup', stopPan);
  };
  $(window).mousemove(continuePan);
  $(window).mouseup(stopPan);
  $(window).contextmenu(cancelMenu);
};
function cancelMenu() {
  $(window).off('contextmenu', cancelMenu);
  return false;
}
$(canvasWrapper).mousedown(startPan);

We start panning on mousedown and continue panning on mousemove. On mouseup, we cancel the panning; we also cancel the mouseup-cancelling function itself.

The right-click menu, also known as the context menu, is cancelled by returning false. The menu-cancelling function also cancels itself. Thus, the context menu will work if you subsequently click outside the canvas wrapper.

Here is a page demonstrating this approach:

http://michaellaszlo.com/so/fabric-pan/

You will see three images on a Fabric canvas (it may take a moment or two for the images to load). You'll be able to use the standard Fabric functionality. You can left-click on the images to move them around, stretch them, and rotate them. But when you right-click within the canvas container, you pan the whole Fabric canvas with the mouse.

Upvotes: 19

Theo Itzaris
Theo Itzaris

Reputation: 4671

i have made an example on jsfiddle , that we can actually drag the whole canvas with all its objects, into a parent div, like the picture,and i will try to explain it step by step.

enter image description here

  1. First of all i download the drag library jquery.dradscroll.js, you can find it on the net. This is a small js file that with little changes it can helps us complete the task. download link: http://www.java2s.com/Open-Source/Javascript_Free_Code/jQuery_Scroll/Download_jquery_dragscroll_Free_Java_Code.htm

  2. create the container that holds our canvas.

      <div class="content">
        <canvas id="c" width="600" height="700" ></canvas>    
      </div>
    
  3. little css

    .content{
       overflow:auto;
       width:400px;
       height:400px;
    }
    
  4. javascript:

    a. create the canvas.

    b. make default cursor , when it is over canvas , open hand

    canvas.defaultCursor = 'url(" http://maps.gstatic.com/intl/en_us/mapfiles/openhand_8_8.cur") 15 15, crosshair';

    c. override the __onMouseDown function , for change to the closedhand cursor( at end).

         fabric.Canvas.prototype.__onMouseDown = function(e){
      // accept only left clicks
      var isLeftClick  = 'which' in e ? e.which === 1 : e.button === 1;
      if (!isLeftClick && !fabric.isTouchSupported) {
        return;
      }
    
    
      if (this.isDrawingMode) {
        this._onMouseDownInDrawingMode(e);
        return;
      }
    
      // ignore if some object is being transformed at this moment
      if (this._currentTransform) {
        return;
      }
    
      var target = this.findTarget(e),
          pointer = this.getPointer(e, true);
    
      // save pointer for check in __onMouseUp event
      this._previousPointer = pointer;
    
      var shouldRender = this._shouldRender(target, pointer),
          shouldGroup = this._shouldGroup(e, target);
    
      if (this._shouldClearSelection(e, target)) {
        this._clearSelection(e, target, pointer);
      }
      else if (shouldGroup) {
        this._handleGrouping(e, target);
        target = this.getActiveGroup();
      }
    
      if (target && target.selectable && !shouldGroup) {
        this._beforeTransform(e, target);
        this._setupCurrentTransform(e, target);
      }
      // we must renderAll so that active image is placed on the top canvas
      shouldRender && this.renderAll();
    
      this.fire('mouse:down', { target: target, e: e });
      target && target.fire('mousedown', { e: e });
    
      if(!canvas.getActiveObject() || !canvas.getActiveGroup()){
        flag=true;
        //change cursor to closedhand.cur
        canvas.defaultCursor = 'url("http://maps.gstatic.com/intl/en_us/mapfiles/closedhand_8_8.cur") 15 15, crosshair';     
    }//end if
    
  5. override the __onMouseUp event ,to change back the cursor to openhand.

       fabric.Canvas.prototype.__onMouseUp = function(e){
           if(flag){
              canvas.defaultCursor = 'url(" http://maps.gstatic.com/intl/en_us/mapfiles/openhand_8_8.cur") 15 15, crosshair';
              flag=false;
           }
        };
    
  6. You initialize the dragScroll() to work on the content that hosts the canvas:

        $('.content').dragScroll({});
    
  7. Some small changes on the jquery.dragScroll.js file, so as to understand when to drag the canvas and when not. On mousedown() event we add an if statement to check whether we have an active object or group.If true ,no canvas drag.

       $($scrollArea).mousedown(function (e) {
        if (canvas.getActiveObject() || canvas.getActiveGroup()) {
            console.log('no drag');return;
        } else {                                             
           console.log($('body'));
            if (typeof options.limitTo == "object") {
                for (var i = 0; i < options.limitTo.length; i++) {
                    if ($(e.target).hasClass(options.limitTo[i])) {
                        doMousedown(e);
                    }
                }
            } else {
                doMousedown(e);
            }
        }
    });
    
  8. on mousedown event we grab the DOM element (.content) and get the top & left position

      function doMousedown(e) {
    
            e.preventDefault();
            down = true;
            x = e.pageX;
            y = e.pageY;        
            top = e.target.parentElement.parentElement.scrollTop; // .content
            left = e.target.parentElement.parentElement.scrollLeft;// .content
        }
    
  9. If we dont want to have the scrollbars visible:

        .content{
       overflow:hidden;
       width:400px;
       height:400px;
    

    }

  10. There is a small problem though,jsfiddle, accepts only https libraries ,so it blocks fabricjs, except if you add it from 'https://rawgit.com/kangax/fabric.js/master/dist/fabric.js', but again it still blocks it some times (at least on my chrome and mozilla).

jsfiddle example : https://jsfiddle.net/tornado1979/up48rxLs/

you may have better luck ,than me, on your browser , but it would definitelly work on your live app.

Anyway, i hope helps ,good luck.

Upvotes: 1

Mi-Creativity
Mi-Creativity

Reputation: 9654

Not sure about FabricJS, but it could be in such a way:

  1. for it to work like in the first video:

    By making use of CSS cursor property, toggling it on mousedown and mouseup events using javascript.

  2. the event handler consume the event (prevent the context menu from appearing, when the user releases the right mouse button):

    Using javascript we return false on contextmenu event

CODE: with a little problem ( * )

using jQuery JS Fiddle 1

$('#test').on('mousedown', function(e){
    if (e.button == 2) {
        // if right-click, set cursor shape to grabbing
        $(this).css({'cursor':'grabbing'});
    }
}).on('mouseup', function(){
    // set cursor shape to default
    $(this).css({'cursor':'default'});
}).on('contextmenu', function(){ 
    //disable context menu on right click
    return false;
});

Using raw javascript JS Fiddle 2

var test = document.getElementById('test');

test.addEventListener('mousedown', function(e){
    if (e.button == 2) {
        // if right-click, set cursor shape to grabbing
        this.style.cursor = 'grabbing';
    }
});
test.addEventListener('mouseup', function(){
    // set cursor shape to default
    this.style.cursor = 'default';
});
test.oncontextmenu = function(){ 
    //disable context menu on right click
    return false;
}


( * ) Problem:

The above snippets works as it should but there's a cross-browser issue, if you check the above fiddles in Firefox - or Opera -you'll see the correct behavior, when checked in Chrome and IE11 - didn't checked it with Edge or Safari - I found that Chrome and IE don't support the grabbing cursor shape, so in the above code snippets, change the cursor lines into this:

jQuery: $(this).css({'cursor':'move'}); JS Fiddle 3

Raw javascript: this.style.cursor = 'move'; JS Fiddle 4

Now we have a working code but without the hand cursor. but there is the following solution:-

SOLUTIONS:

  1. Chrome and Safari support grab and grabbing with the -webkit- prefix like:

    $(this).css({'cursor': '-webkit-grabbing'});
    

    but you need to make browser sniffing first, if Firefox then the default and standard code, if Chrome and Safari then with the -webkit- prefix, and this still makes IE out of the game.

  2. Have a look at this example, test it with Chrome, Safari, Firefox, Opera and IE you can see that cursor: url(foo.bar) works and supported in ALL browsers. Chrome, Safari, Firefox and Opera shows the yellow smile image smiley.gif, but IE shows the red ball cursor url(myBall.cur).

    So I think you can make use of this, and a grabbing hand image like this enter image description here

    Or this:

    enter image description here

    You can use an image like the above, a png or gif format with all browsers except IE which supports .cur, so you need to find a way to convert it into a .cur. Google search shows many result of convert image to cur

Note that, although this cursor:url(smiley.gif),url(myBall.cur),auto; - with fallback support separated by comma, works well in the W3Schools example shown above, I couldn't get it to work the same way in javascript, I tried $(this).css({'cursor': 'grabbing, move'}); but it didn't work. I also tried doing it as CSS class

.myCursor{ cursor: grabbing, -webkit-grabbing, move; }

Then with jQuery $(this).addClass('myCursor'); but no avail either.

So you still need to make browser sniffing whether you are going the second solution or a hybrid fix of the both solutions, this is my code which I've used couple times to detect browser and it worked well at the time of this post but you porbabely won't need the "Mobile" and "Kindle" parts.

// Detecting browsers
$UA = navigator.userAgent;

if ($UA.match(/firefox/i)) {
	$browser = 'Firefox';
} else if ($UA.indexOf('Trident') != -1 && $UA.indexOf('MSIE') == -1) {
	$browser = 'MSIE';
} else if ($UA.indexOf('MSIE') != -1) {
	$browser = 'MSIE';
} else if ($UA.indexOf('OPR/') != -1) {
	$browser = 'Opera';
} else if ($UA.indexOf("Chrome") != -1) {
	$browser = 'Chrome';
} else if ($UA.indexOf("Safari")!=-1) {
	$browser = 'Safari';
}
if($UA.match(/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Nokia|Mobile|Opera Mini/i)) {
	$browser = 'Mobile';
}else if($UA.match(/KFAPWI/i)){
	$browser = 'Kindle';
}

console.log($browser);


Resources:

Upvotes: 2

Related Questions