Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save mindofjonas/c373b40b48c34fc60fc5024817444745 to your computer and use it in GitHub Desktop.

Select an option

Save mindofjonas/c373b40b48c34fc60fc5024817444745 to your computer and use it in GitHub Desktop.
Fabric.js Canvas Drag, Zoom and Pinch for Mouse and Touch

Fabric.js Canvas Drag, Zoom and Pinch for Mouse and Touch

This demo is based on another Fabric.js pen comparing drag and zoom performance using native functions and CSS transform.

This pen is the subsequent development of that comparison with the following changes:

  • dragging now only uses CSS transform
  • all events triggering method calls are throttled and debounced using Lodash
  • the canvas can't be dragged outside of the viewable area

A Pen by Fjonan on CodePen.

License.

<html lang="en" data-bs-theme="dark">
<!-- This is to prevent default pinch-behavior of the CodePen website -->
<body style="touch-action:none;">
<section class="canvas-wrapper overflow-hidden position-relative m-auto d-inline-block border">
<aside class="controls position-absolute top-0 start-0 bg-glass p-2 rounded-end rounded-top-0 border small">
<button class="btn btn-outline-primary mt-2 w-100" id="resetCanvas">
Reset canvas
</button>
</aside>
<canvas id=canvas>
</canvas>
</section>
<div class="modal fade" id="infoModal" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h1 class="h5 modal-title">
Fabric.js Canvas Drag, Zoom and Pinch
<span class="d-block fs-6">for mouse and touch</span>
</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>This demo is based on another <b>Fabric.js</b> pen <a href="https://codepen.io/Fjonan/pen/azoWXWJ" target="_blank">comparing drag and zoom performance</a> using native functions and <b>CSS transform</b>.</p>
<p>This pen is the subsequent development of that comparison with the following changes:</p>
<ul>
<li>
dragging now only uses CSS transform
</li>
<li>
all events triggering method calls are <code>throttled</code> and <code>debounced</code> using <b>Lodash</b>
</li>
<li>
the canvas can't be dragged outside of the viewable area
</li>
</ul>
<p>
I wrote an article
<a href="https://medium.com/@Fjonan/performant-drag-and-zoom-using-fabric-js-3f320492f24b" target="_blank">Performant Drag and Zoom Using Fabric.js</a> explaining the details of this demo.
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-success" data-bs-dismiss="modal">Understood</button>
</div>
</div>
</div>
</div>
import { Canvas, Rect, util } from "https://esm.sh/[email protected]";
(function () {
// Configuration for number of elements
const elementSize = 20
const gap = elementSize / 3
const elementColumns = 15
const elementRows = 10
const blockColumns = 2
const blockRows = 3
const elemDim = elementSize + gap
const blockGap = gap * 2
const wrapper = document.querySelector('.canvas-wrapper')
const rect = new Rect({
width: elementSize,
height: elementSize,
fill: '#0ebeff',
selectable: false,
hasBorders: false,
hasControls: false,
hasRotatingPoint: false,
evented: false,
objectCaching: false, // important for mobile performance
noScaleCache: true, // important for mobile performance
})
let lastPosX = 0,
lastPosY = 0,
touchZoom = 1,
initialDistance = 0,
pinchCenter
// after scaling we transform the CSS scale to canvas zoom so it does not stay blurry
const debouncedScale2Zoom = _.debounce(canvasScaleToZoom, 1000)
// limit visual updates to happen 60 times per second, which is enough
const throttledTranslateCanvas = _.throttle(translateCanvas, 16) // 16.66 ms ~ 60 frames per second
const throttledScaleCanvas = _.throttle(scaleCanvas, 16)
document.getElementById('resetCanvas').addEventListener('click', resetCanvas)
/**
* Creates Blocks of rectangles so we have a lot of elements to stress test the performance
*/
function createElements() {
// create block rows
for (var l = 0; l < blockRows; l++)
{
const topBlock = blockGap + ( l * ((elemDim * elementRows) + blockGap))
// create block columns
for (var k = 0; k < blockColumns; k++)
{
const left = blockGap + (k * ((elemDim * elementColumns) + blockGap))
// create element rows
for (var j = 0; j < elementRows; j++)
{
const top = j * (elementSize + gap)
// create element columns
for (var i = 0; i < elementColumns; i++)
{
const newRect = _.clone(rect)
newRect.left = left + (i * elemDim)
newRect.top = top + topBlock
canvas.add(newRect);
}
}
}
}
}
/**
* 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,
}
}
/**
* Calculates and caps the container offset relative to the wrapper
*/
function capCanvasOffset(offset, containerDimension, wrapperDimension) {
const maxPositiveOffset = wrapperDimension * .50
offset = Math.max(offset, (containerDimension - maxPositiveOffset) * -1)
offset = Math.min(offset, maxPositiveOffset)
return offset
}
/**
* Putting touch point coordinates into an object
*/
function getPinchCoordinates(touch1, touch2) {
return {
x1: touch1.clientX,
y1: touch1.clientY,
x2: touch2.clientX,
y2: touch2.clientY,
}
}
/**
* Returns the distance between two touch points
*/
function getPinchDistance(touch1, touch2) {
const coord = getPinchCoordinates(touch1, touch2)
return Math.sqrt(Math.pow(coord.x2 - coord.x1, 2) + Math.pow(coord.y2 - coord.y1, 2))
}
/**
* Pinch center around wich the canvas will be scaled/zoomed
* takes into account the translation of the container element
*/
function setPinchCenter(touch1, touch2) {
const coord = getPinchCoordinates(touch1, touch2)
const currentX = (coord.x1 + coord.x2) / 2
const currentY = (coord.y1 + coord.y2) / 2
const transform = getTransformVals(canvas.wrapperEl)
pinchCenter = {
x: currentX - transform.translateX,
y: currentY - transform.translateY,
}
}
/**
* Reset all applied styles and changes to position and zoom
*/
function resetCanvas() {
canvas.wrapperEl.style.transform = ``
canvas.wrapperEl.style.setProperty('--tOriginX', '0px')
canvas.wrapperEl.style.setProperty('--tOriginY', '0px')
canvas.viewportTransform = [1, 0, 0, 1, 0, 0]
canvas.setHeight((elemDim * elementRows * blockRows) + (blockGap * (blockRows+1)))
canvas.setWidth((elemDim * elementColumns * blockColumns) + (blockGap * (blockColumns+1)))
canvas.setViewportTransform(canvas.viewportTransform)
touchZoom = 1
}
/**
* Creat FabriJS Canvas and register relevant event handler
*/
function createCanvas(width, height, zoom) {
const canvas = new Canvas("canvas", {
allowTouchScrolling: false,
width: width,
height: height,
zoom: zoom,
defaultCursor: 'grab',
selection: false,
renderOnAddRemove: false,
skipTargetFind: true,
})
canvas.on("mouse:down", dragCanvasStart)
canvas.on("mouse:move", dragCanvas)
canvas.on("mouse:up", () => {canvas.defaultCursor = 'grab'})
canvas.on('mouse:wheel', zoomCanvasMouseWheel)
return canvas
}
/**
* Register touch events since FabricJS does not (yet) offer touch events
* (only with a wildly outdated custom build)
*/
function registerTouchEventHandler() {
wrapper.addEventListener('touchstart', (event) => {
dragCanvasStart(event.targetTouches[0])
pinchCanvasStart(event)
})
wrapper.addEventListener('touchmove', (event) => {
pinchCanvas(event)
dragCanvas(event.targetTouches[0])
})
wrapper.addEventListener('touchend', pinchCanvasEnd)
}
/**
* Save reference point from which the interaction started
*/
function dragCanvasStart(event) {
const evt = event.e || event // retrieving original event from fabricJS event
lastPosX = evt.clientX
lastPosY = evt.clientY
canvas.defaultCursor = 'grabbing'
}
/**
* Start Dragging the Canvas using Fabric JS Events
*/
function dragCanvas(event) {
const evt = event.e || event // retrieving original event from fabricJS event
if (1 !== evt.buttons && !(evt instanceof Touch)) {
return
}
throttledTranslateCanvas(evt)
}
/**
* Convert the movement to CSS translate which visually moves the canvas as if being dragged
*/
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()
const offsetXCapped = capCanvasOffset(offsetX, transform.width, viewBox.width)
const offsetYCapped = capCanvasOffset(offsetY, transform.height, viewBox.height)
canvas.wrapperEl.style.transform = `translate(${offsetXCapped}px, ${offsetYCapped}px) scale(${transform.scaleX})`
lastPosX = event.clientX
lastPosY = event.clientY
console.log('translate end')
}
/**
* Save the distance between the touch points when starting the pinch
*/
function pinchCanvasStart(event) {
if (event.touches.length !== 2) {
return
}
initialDistance = getPinchDistance(event.touches[0], event.touches[1])
}
/**
* Start pinch-zooming the canvas
*/
function pinchCanvas(event) {
if (event.touches.length !== 2) {
return
}
setPinchCenter(event.touches[0], event.touches[1])
const currentDistance = getPinchDistance(event.touches[0], event.touches[1])
let scale = (currentDistance / initialDistance).toFixed(2)
scale = 1 + (scale - 1) / 20 // slows down scale from pinch
throttledScaleCanvas(scale * touchZoom, pinchCenter)
}
/**
* Re-Draw the canvas after pinching ended
*/
function pinchCanvasEnd(event) {
if (2 > event.touches.length) {
console.log('pinch end')
debouncedScale2Zoom()
}
}
/**
* Convert zoom to CSS scale which visually zooms the canvas by scaling it up in place
*/
function scaleCanvas(zoom, aroundPoint) {
const tVals = getTransformVals(canvas.wrapperEl)
zoom = Math.min(zoom, 2)
zoom = Math.max(zoom, .7)
if (zoom === touchZoom) {
return -1
}
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 scale to Fabric.js zoom so the blurry image gets sharp again
*/
function canvasScaleToZoom() {
if (canvas.getZoom() === touchZoom) {
return
}
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 resize the canvas to the scaled values
canvas.setDimensions({height:transform.height, width: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')
}
/**
* 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}
throttledScaleCanvas(zoom, point)
debouncedScale2Zoom()
}
// Prep the show
const width = (elemDim * elementColumns * blockColumns) + (blockGap * (blockColumns+1))
const height = (elemDim * elementRows * blockRows) + (blockGap * (blockRows+1))
const zoom = 1
var canvas = createCanvas(width, height, zoom)
createElements()
registerTouchEventHandler()
canvas.requestRenderAll()
// show greeting
const infoModal = new bootstrap.Modal('#infoModal')
infoModal.show()
})()
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.min.js"></script>
body {
padding: 0;
margin: 0;
height: 100vh;
width: 100vw;
display: flex;
}
.controls {
z-index: 1;
width: 250px;
}
.bg-glass {
-webkit-backdrop-filter: saturate(180%) blur(6px);
backdrop-filter: saturate(180%) blur(6px);
background: hsla(0, 0%, 100%, .8);
}
.canvas-wrapper {
width: 100%;
height: 100%;
}
.canvas-container {
--tOriginX: 0px;
--tOriginY: 0px;
transform-origin: var(--tOriginX) var(--tOriginY);
&::after { /** visualize transform origin */
position: absolute;
top: var(--tOriginY);
left: var(--tOriginX);
width: 5px;
height: 5px;
content: '';
background-color: #e60064;
border-radius: 50%;
transform: translate(-50%, -50%);
z-index: 3;
pointer-events: none; /** otherwise it is the target of mouse/touch events */
}
}
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css" rel="stylesheet" />
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment