Introduction
Welcome to our inaugural development blog post! Today, we’re diving deep into how you can seamlessly implement mobile gestures, such as pinch-to-zoom and drag-to-pan, in Fabric.js. While developing this feature for SketchMate, we faced some challenges, and we hope that sharing our experiences can make your journey easier.
Experience these features firsthand on the SketchMate Web App—just make sure you’re on a mobile device!
Overview of Our Strategy
Our approach to gesture detection hinges on several key events, namely:
- Gesture Start: Initialize the canvas for incoming gestures.
- Zoom: Handle both canvas zooming and object scaling.
- Rotate: Deal with object rotation.
- Drag: Manage canvas panning.
- Gesture End: Revert changes initialized during the “Gesture Start”.
We opted for a custom implementation for maximum customization. The full code is available on our Github repository, although any event detection library can be used such as HammerJS or AlloyFinger.
Note: For simplicity’s sake we will trim down the code we use in production.
Gesture Start
js
onGestureStart: () => {canvas = // init fabric.js canvas// Rotation or scale gesturesif (selectedTool.value == DrawTool.Select) {const obj = canvas.getActiveObject()obj.set({ lockMovementX: true, lockMovementY: true }) // Disable translation during rotation/scale gestures}// Zoom or pan gestureselse {canvas.getObjects().forEach((o: any) => (o.objectCaching = false)) // Performance optimizationcancelPreviousAction(c)}}
Key Takeaways:
- Disabling object caching improves performance during zoom gestures.
- We use cancelPreviousAction to stop unintended touch inputs from affecting the canvas.
- Locking object movement in X and Y axis to prevent unwanted translation during rotating/scaling.
js
function cancelPreviousAction(c: Canvas) {setTimeout(() => {fabricateTouchUp(c)const lastObject = c.getObjects().pop()if (lastObject) c.remove(lastObject)}, 1)}
Rotate event
We rotate the object:
Key Takeaways:js
onRotate: (angleDifference: number, previousAngle: number) => {if (!(selectedTool.value == DrawTool.Select)) returnconst rotationThreshold = 0.45 // Adjust the threshold as neededconst obj = c.getActiveObject()isRotating = Math.abs(angleDifference) > rotationThresholdif (!isRotating) returnobj.rotate((obj.angle! + angleDifference) % 360)obj.setCoords() // might not be necessaryc.requestRenderAll()}
- The rotationThreshold allows us to distinguish between intended rotations and accidental movements.
Zoom event
Key Takeaways:js
onZoom: (scale: number, previousScale: number, center: IPoint) => {// Object scalingif (selectedTool.value == DrawTool.Select) {if (isRotating) return // In case we are rotating, we do not do anythingif (Math.abs(scale - previousScale) < 0.005) return // Ignore small scale changes, these are probably not intentionalconst scaleDiff = scale - previousScaleconst obj = c.getActiveObject()obj._setOriginToCenter()obj.scaleX! *= 1 + scaleDiffobj.scaleY! *= 1 + scaleDiffobj._resetOrigin()obj.setCoords()c.requestRenderAll()} else {// Canvas zoomingif (Math.abs(scale - previousScale) < 0.005) returnhandleZoom(scale, center.x, center.y, c, previousScale)c.requestRenderAll()}}// Handle zoomingexport const handleZoom = (scale: number, centerX: number, centerY: number, c: Canvas, previousScale: number) => {let newZoom = c.getZoom() * Math.pow(scale / previousScale, 1)// Limit the zoom level to the maximum and minimum valuesnewZoom = Math.min(newZoom, 10)newZoom = Math.max(newZoom, 1)// Get the center point of the gestureconst gestureCenter = new fabric.Point(centerX, centerY)// Zoom the canvas to the new zoom level while maintaining the gesture center pointc.zoomToPoint(gestureCenter, newZoom)checkCanvasBounds(c)}// Makes sure that we remain within the canvas areaexport const checkCanvasBounds = (c: Canvas) => {const vpt = c.viewportTransform!if (vpt[4] >= 0) {vpt[4] = 0} else if (vpt[4] < c.getWidth() - c.getWidth() * c.getZoom()) {vpt[4] = c.getWidth() - c.getWidth() * c.getZoom()}if (vpt[5] >= 0) {vpt[5] = 0} else if (vpt[5] < c.getHeight() - c.getHeight() * c.getZoom()) {vpt[5] = c.getHeight() - c.getHeight() * c.getZoom()}c.setViewportTransform(vpt)}
- We’ve set up guards to ignore minute changes in scaling, ensuring a smooth user experience.
- Zoom limits are implemented to prevent excessive zoom-in and zoom-out.
Drag event
Key Takeaways:js
onDrag: (dx: number, dy: number, previousDx: number, previousDy: number, center: IPoint) => {if (selectedTool.value == DrawTool.Select) returnconst delta = {x: 2 * (dx - previousDx),y: 2 * (dy - previousDy)}handlePan(delta, c)c.requestRenderAll()}export const handlePan = (delta: IPoint, c: Canvas) => {c.relativePan(delta)checkCanvasBounds(c)}
- Drag events are disabled when the select tool is active to avoid conflicts.
- Relative panning is used for smoother dragging of the canvas.
Gesture end event
Key Takeaways:js
onGestureEnd: (fingers: number) => {if (selectedTool.value != DrawTool.Select && fingers == 1) {c.getObjects().forEach((o: any) => (o.objectCaching = true))}if (selectedTool.value == DrawTool.Select && fingers == 0) {const obj = c.getActiveObject()obj.set({ lockMovementX: false, lockMovementY: false })}c.requestRenderAll()}
- Object caching is re-enabled to optimize rendering performance after the drag gesture is completed.
- Movement locks on objects are released, allowing further interaction.
- The event is tailored to execute logic only when all fingers are lifted off the screen to prevent unwanted object translations caused by Fabric.js.
Conclusion
And there you have it! Your roadmap to implementing intuitive mobile gestures in Fabric.js 😎.
While Fabric.js is a good library, it often requires bespoke solutions for specific use-cases—just like this one. We hope this guide proves helpful. Feel free to share your thoughts and let us know if you’d like more content like this :)