d0001
d0001

Reputation: 2190

Fabricjs pan and zoom

How can I pan and zoom using fabricjs? I've tried using the methods zoomToPoint and setZoom but they do not work for panning. Once I start using different zoom points I start having trouble.

$('#zoomIn').click(function(){
    canvas.setZoom(canvas.getZoom() * 1.1 ) ;
}) ;

$('#zoomOut').click(function(){
    canvas.setZoom(canvas.getZoom() / 1.1 ) ;
}) ;

$('#goRight').click(function(){
    //Need to implement
}) ;

$('#goLeft').click(function(){
    //Need to implement
}) ;

http://jsfiddle.net/hdramos/ux16013L/

Upvotes: 23

Views: 28876

Answers (7)

Fjonan
Fjonan

Reputation: 56

I solved this using CSS transform. I made a demo comparing drag and zoom using Fabric.js versus using CSS transform: https://codepen.io/Fjonan/pen/azoWXWJ The demo also includes working code four touch drag an pinch gesture.

Tl;dr: CSS transform ist vastly more performant no matter the complexity of the canvas.


I had serious performance problems when implementing drag and zoom using Fabric.js on canvases with lots of elements. I solved it by using CSS transform: translate() and scale() instead. For my purpose it was fine to move the canvas inside a wrapper element with overflow: hidden, so I would not need to re-draw the content when moving the canvas around.

However when you scale the canvas, the image gets blurry so after zoom you need to re-draw the content so the image gets sharp again.

Since the CSS transform solution is much better in my opinion I will go with that here. If you insist on doing with with Fabric.js you can see the code for that in my CodePen demo.


Setup something like this:

<section class="canvas-wrapper overflow-hidden position-relative">
  <canvas id=canvas>
  </canvas>
</section>

Fabric.js will create its own wrapper element canvas-container which I access here using canvas.wrapperEl.

This code handles dragging:

const wrapper = document.querySelector('.canvas-wrapper')
const canvas = new fabric.Canvas("canvas",{
  allowTouchScrolling: false,
  defaultCursor: 'grab',
  selection: false,
  // …
})
let lastPosX, 
    lastPosY

canvas.on("mouse:down", dragCanvasStart)
canvas.on("mouse:move", dragCanvas)

/**
 * Save reference point from which the interaction started
 */
function dragCanvasStart(event) {
  const evt = event.e // retrieving original event from fabricJS event
    
  // save thew position you started dragging from
  lastPosX = evt.clientX
  lastPosY = evt.clientY
}

/**
 * Start Dragging the Canvas using Fabric JS Events
 */
function dragCanvas(event) {    
  const evt = event.e // retrieving original event from fabricJS event

  if (1 !== evt.buttons) { // left mouse button is pressed
    return
  }
    
  translateCanvas(evt)
}

/**
 * Convert movement to CSS translate which visually moves the canvas
 */
function translateCanvas(event) {    
  const transform = getTransformVals(canvas.wrapperEl)

  let offsetX = transform.translateX + (event.clientX - (lastPosX || 0))
  let offsetY = transform.translateY + (event.clientY - (lastPosY || 0))

  const viewBox = wrapper.getBoundingClientRect()

  canvas.wrapperEl.style.transform = `translate(${tVals.translateX}px, ${tVals.translateY}px) scale(${transform.scaleX})`

  lastPosX = event.clientX
  lastPosY = event.clientY
}

/**
 * Get relevant style values for the given element
 * @see https://stackoverflow.com/a/64654744/13221239
 */
function getTransformVals(element) {
  const style = window.getComputedStyle(element)
  const matrix = new DOMMatrixReadOnly(style.transform)    
  return {
    scaleX: matrix.m11,
    scaleY: matrix.m22,
    translateX: matrix.m41,
    translateY: matrix.m42,
    width: element.getBoundingClientRect().width,
    height: element.getBoundingClientRect().height,
  }
}

And this code will handle zoom:

let touchZoom
canvas.on('mouse:wheel', zoomCanvasMouseWheel)

// after scaling transform the CSS to canvas zoom so it does not stay blurry
// @see https://lodash.com/docs/4.17.15#debounce
const debouncedScale2Zoom = _.debounce(canvasScaleToZoom, 1000) 

/**
 * Zoom canvas when user used mouse wheel
 */
function zoomCanvasMouseWheel(event) {
  const delta = event.e.deltaY
  let zoom = touchZoom

  zoom *= 0.999 ** delta
  const point = {x: event.e.offsetX, y: event.e.offsetY}
    
  scaleCanvas(zoom, point)
  debouncedScale2Zoom()
}

/**
 * Convert zoom to CSS scale which visually zooms the canvas
 */
function scaleCanvas(zoom, aroundPoint) {
  const tVals = getTransformVals(canvas.wrapperEl)
  const scaleFactor = tVals.scaleX / touchZoom * zoom

  // see CSS: transform-origin: var(--tOriginX) var(--tOriginY);
  canvas.wrapperEl.style.setProperty('--tOriginX', `${aroundPoint.x}px`)
  canvas.wrapperEl.style.setProperty('--tOriginY', `${aroundPoint.y}px`)

  canvas.wrapperEl.style.transform = `translate(${tVals.translateX}px, ${tVals.translateY}px) scale(${scaleFactor})`

  touchZoom = zoom
}

/**
 * Converts CSS transform to Fabric.js zoom so the blurry image gets sharp 
 */
function canvasScaleToZoom() {    
  const transform = getTransformVals(canvas.wrapperEl)
  const canvasBox = canvas.wrapperEl.getBoundingClientRect()
  const viewBox = wrapper.getBoundingClientRect()

  // calculate the offset of the canvas inside the wrapper
  const offsetX = canvasBox.x - viewBox.x
  const offsetY = canvasBox.y - viewBox.y

  // we resizen the canvas to the scaled values
  canvas.setHeight(transform.height)
  canvas.setWidth(transform.width)
  canvas.setZoom(touchZoom)

  // and reset the transform values
  canvas.wrapperEl.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(1)`
  canvas.wrapperEl.style.setProperty('--tOriginX', '0px')
  canvas.wrapperEl.style.setProperty('--tOriginY', '0px')

  canvas.renderAll()
}

Additional note on performance: When working on this I came across an issue with performance when zooming on mobile devices. After a helpful discussion I could solve it by setting these two properties on the objects in the canvas:

new Rect({
  // …
  objectCaching: false, // important for mobile performance
  noScaleCache: true, // important for mobile performance
})

I want to highlight this quote from Asturur, a maintainer of Fabric.js:

if you are using simple shapes that are a single draw call ( rect, circle, small polygon, ellipse, triangle ) and you are using only fill or only stroke, caching is at loss

So consider turning off caching for these kind of shapes to optimize performance. Strongly depends on your circtumstances.

I created a more extensive write up with extra code and reasoning if anybody needs more information.

Upvotes: 0

DocAmz
DocAmz

Reputation: 96

Zoom to center for button with percentage steps and limiter with the method :

Canvas.zoomToPoint(point: Point, value: number, skipSetCoords?: boolean): void

const zoomCanvas = (cmd: "+" | "-") => {

    let: percent = Math.round(canvas.getZoom()*100) //starting zoom level in percentage

    const step = 5 // zoom step in percentage
    const max = 500 // max zoom level in percentage
    const min = 5 // min zoom level in percentage

    let zoom = 5
    if(cmd === "+" && percent <= max){
        zoom = Math.min(percent + step, max) // if command is '+' and percentage is lower than max then zoom in
    }
    else if  (cmd === "-" && percent >= min){
        zoom = Math.max(percent - step, min) // if command is '-' and percentage is greater than min then zoom out
    } else { return }

    const point = canvas.getCenterPoint() // get canvas centerPoint or any fabric.Point
    canvas.zoomToPoint(point, zoom / 100) // zoom to point
}

Set a custom zoom percentage with limiter

  const setCanvasZoomPercentage = (value: number) => {

    const center = canvas.getCenterPoint(); // get canvas centerPoint or any fabric.Point
    const max = 500; // max zoom level in percentage
    const min = 5; // min zoom level in percentage

    let zoom = value;

    if (zoom > max) v = max; // if value is greater than max
    if (zoom < min) v = min; // if value is lower than min

    canvas.zoomToPoint(center, zoom / 100);// center or any fabric.Point
  }

You can adapt min, max and step

Upvotes: 0

Sabatino
Sabatino

Reputation: 389

I have an example on Github using fabric.js Canvas panning and zooming: 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: 4

Milan Hlin&#225;k
Milan Hlin&#225;k

Reputation: 4420

Here is my solution for canvas zoom (using mouse wheel) and pan (using left/up/right /down keys or shift key + mouse left down + mouse move).

https://jsfiddle.net/milanhlinak/7s4w0uLy/8/

<!DOCTYPE html>
<html>

<head>
    <script type="text/javascript" src="lib/jquery-3.1.1.min.js"></script>
    <script type="text/javascript" src="lib/fabric.min.js"></script>
</head>

<body>

    <canvas id="canvas" style="border: 1px solid #cccccc"></canvas>

    <script>
        var Direction = {
            LEFT: 0,
            UP: 1,
            RIGHT: 2,
            DOWN: 3
        };

        var zoomLevel = 0;
        var zoomLevelMin = 0;
        var zoomLevelMax = 3;

        var shiftKeyDown = false;
        var mouseDownPoint = null;

        var canvas = new fabric.Canvas('canvas', {
            width: 500,
            height: 500,
            selectionKey: 'ctrlKey'
        });

        canvas.add(new fabric.Rect({
            left: 100,
            top: 100,
            width: 50,
            height: 50,
            fill: '#faa'

        }));
        canvas.add(new fabric.Rect({
            left: 300,
            top: 300,
            width: 50,
            height: 50,
            fill: '#afa'
        }));

        canvas.on('mouse:down', function (options) {
            var pointer = canvas.getPointer(options.e, true);
            mouseDownPoint = new fabric.Point(pointer.x, pointer.y);
        });
        canvas.on('mouse:up', function (options) {
            mouseDownPoint = null;
        });
        canvas.on('mouse:move', function (options) {
            if (shiftKeyDown && mouseDownPoint) {
                var pointer = canvas.getPointer(options.e, true);
                var mouseMovePoint = new fabric.Point(pointer.x, pointer.y);
                canvas.relativePan(mouseMovePoint.subtract(mouseDownPoint));
                mouseDownPoint = mouseMovePoint;
                keepPositionInBounds(canvas);
            }
        });
        fabric.util.addListener(document.body, 'keydown', function (options) {
            if (options.repeat) {
                return;
            }
            var key = options.which || options.keyCode; // key detection
            if (key == 16) { // handle Shift key
                canvas.defaultCursor = 'move';
                canvas.selection = false;
                shiftKeyDown = true;
            } else if (key === 37) { // handle Left key
                move(Direction.LEFT);
            } else if (key === 38) { // handle Up key
                move(Direction.UP);
            } else if (key === 39) { // handle Right key
                move(Direction.RIGHT);
            } else if (key === 40) { // handle Down key
                move(Direction.DOWN);
            }
        });
        fabric.util.addListener(document.body, 'keyup', function (options) {
            var key = options.which || options.keyCode; // key detection
            if (key == 16) { // handle Shift key
                canvas.defaultCursor = 'default';
                canvas.selection = true;
                shiftKeyDown = false;
            }
        });
        jQuery('.canvas-container').on('mousewheel', function (options) {
            var delta = options.originalEvent.wheelDelta;
            if (delta != 0) {
                var pointer = canvas.getPointer(options.e, true);
                var point = new fabric.Point(pointer.x, pointer.y);
                if (delta > 0) {
                    zoomIn(point);
                } else if (delta < 0) {
                    zoomOut(point);
                }
            }
        });

        function move(direction) {
            switch (direction) {
            case Direction.LEFT:
                canvas.relativePan(new fabric.Point(-10 * canvas.getZoom(), 0));
                break;
            case Direction.UP:
                canvas.relativePan(new fabric.Point(0, -10 * canvas.getZoom()));
                break;
            case Direction.RIGHT:
                canvas.relativePan(new fabric.Point(10 * canvas.getZoom(), 0));
                break;
            case Direction.DOWN:
                canvas.relativePan(new fabric.Point(0, 10 * canvas.getZoom()));
                break;
            }
            keepPositionInBounds(canvas);
        }


        function zoomIn(point) {
            if (zoomLevel < zoomLevelMax) {
                zoomLevel++;
                canvas.zoomToPoint(point, Math.pow(2, zoomLevel));
                keepPositionInBounds(canvas);
            }
        }

        function zoomOut(point) {
            if (zoomLevel > zoomLevelMin) {
                zoomLevel--;
                canvas.zoomToPoint(point, Math.pow(2, zoomLevel));
                keepPositionInBounds(canvas);
            }
        }

        function keepPositionInBounds() {
            var zoom = canvas.getZoom();
            var xMin = (2 - zoom) * canvas.getWidth() / 2;
            var xMax = zoom * canvas.getWidth() / 2;
            var yMin = (2 - zoom) * canvas.getHeight() / 2;
            var yMax = zoom * canvas.getHeight() / 2;

            var point = new fabric.Point(canvas.getWidth() / 2, canvas.getHeight() / 2);
            var center = fabric.util.transformPoint(point, canvas.viewportTransform);

            var clampedCenterX = clamp(center.x, xMin, xMax);
            var clampedCenterY = clamp(center.y, yMin, yMax);

            var diffX = clampedCenterX - center.x;
            var diffY = clampedCenterY - center.y;

            if (diffX != 0 || diffY != 0) {
                canvas.relativePan(new fabric.Point(diffX, diffY));
            }
        }

        function clamp(value, min, max) {
            return Math.max(min, Math.min(value, max));
        }
    </script>

</body>

</html>

Upvotes: 9

Dudi
Dudi

Reputation: 3079

If you just want to pan the canvas in the display, and not to change the position of the elements, you can use this solution.

The idea is to have a fixed size container with css of overflow: hidden that has an expended canvas inside it. the pan will move the canvas inside the container so the user will see different areas of the expended canvas every time.

Upvotes: 0

gncabrera
gncabrera

Reputation: 645

I know it is already answered, but I had to do a mouse panning, so I adapted the fiddle of the accepted answer to do so. I post it here for anyone who has to do something like this. This is just the main idea:

var panning = false;
canvas.on('mouse:up', function (e) {
    panning = false;
});

canvas.on('mouse:down', function (e) {
    panning = true;
});
canvas.on('mouse:move', function (e) {
    if (panning && e && e.e) {
        var units = 10;
        var delta = new fabric.Point(e.e.movementX, e.e.movementY);
        canvas.relativePan(delta);
    }
});

Here is the fiddle: http://jsfiddle.net/gncabrera/hkee5L6d/5/

Upvotes: 19

d0001
d0001

Reputation: 2190

Solved it using:

relativePan() absolutePan()

[Update]

$('#goRight').click(function(){
    var units = 10 ;
    var delta = new fabric.Point(units,0) ;
    canvas.relativePan(delta) ;
}) ;

$('#goLeft').click(function(){
    var units = 10 ;
    var delta = new fabric.Point(-units,0) ;
    canvas.relativePan(delta) ;
}) ;
$('#goUp').click(function(){
    var units = 10 ;
    var delta = new fabric.Point(0,-units) ;
    canvas.relativePan(delta) ;
}) ;

$('#goDown').click(function(){
    var units = 10 ;
    var delta = new fabric.Point(0,units) ;
    canvas.relativePan(delta) ;
});

http://jsfiddle.net/ux16013L/2/

Upvotes: 23

Related Questions