This article will guide you through the implementation of zoom functionality using fabric.js library.
Install Fabric JS lib 🤦♂️
Add post install script in your package.json file’s scripts:
"postinstall": "cd node_modules/fabric && yarn && npm run build_with_gestures"
run ‘yarn’ in command line. It will build fabric.js with gesture support.
Now the code. You always into to know your contents height and weight that you are rendering of the canvas. Because it will allow content overflow or underflow management.
The code below will not work out of the box without some tweaks here and there. Because the code was created for specific scenario. But in most of the cases it will need little tinkering. First read the code and then you will be able to understand what’s going on. It is not a library 😑, so you have to do some work ⚒️.
Zooming (Scaling)/Skewing/Translation is controlled by transform function of canvas. Fabric JS provide the tools to implement your own logic easily. Read more about transform: CanvasRenderingContext2D: transform() method – Web APIs | MDN (mozilla.org)
export const fabricJSZoom = (
canvasEl: RefObject<HTMLCanvasElement>,
scrollWhenCanvasIsHidden: RefObject<HTMLElement> | undefined, // Element which is scrollable. If canvas is overflown
) => {
const isMouseDown = useRef(false);
const previousMouseDown = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const zoomStartScale = useRef<number>(1);
const zoomOffsetStart = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const dimensionInfo = useRef({
widthAndHeight: { width: 0, height: 0 },
});
const fabricCanvas = useRef<fabric.Canvas>();
// So that we know, what was the VPT when zoom or scroll started.
const viewPortInitialTransform = useRef<IStaticCanvasOptions['viewportTransform'] | null>(null);
const addEvents = async (canvas: fabric.Canvas) => {
const { widthAndHeight } =
dimensionInfo.current;
// Transform canvas on mouse move,
// so that overflow items could be visible when zoomed
canvas.on('mouse:move', (opt) => {
if (isMouseDown.current) {
let { screenX, screenY } = opt.e;
if (opt.e instanceof TouchEvent) {
// if touches are more than one, then it could be zoom. so don't move.
if (opt.e.touches.length > 1) {
return;
}
const { screenX: screenXt, screenY: screenYt } = opt.e.touches[0];
screenX = screenXt;
screenY = screenYt;
}
const { x: pScreenX, y: pScreenY } = previousMouseDown.current;
const movementX = -(pScreenX - screenX);
const movementY = -(pScreenY - screenY);
previousMouseDown.current = { x: screenX, y: screenY };
transformMove(movementX, movementY);
}
});
const transformMove = (movementX: number, movementY: number) => {
const currentTransform = canvas.viewportTransform;
if (currentTransform && currentTransform.length > 5) {
const newTransform = [...currentTransform];
newTransform[4] += movementX;
newTransform[5] += movementY;
const maxXMovementAllowed =
widthAndHeight.width * canvas.getZoom() - widthAndHeight.width;
const maxYMovementAllowed = totalHeight * canvas.getZoom() - widthAndHeight.height;
console.log(maxXMovementAllowed, maxYMovementAllowed);
// For X Axis management
if (
Math.abs(newTransform[4]) >= maxXMovementAllowed ||
newTransform[4] > 0 ||
// If X is 0 and then try to scroll the container
(currentTransform[4] === 0 &&
scrollWhenCanvasIsHidden?.current &&
scrollWhenCanvasIsHidden?.current.scrollLeft > 0)
) {
newTransform[4] = currentTransform[4];
// if parent can scroll, then scroll it
if (scrollWhenCanvasIsHidden?.current) {
const scrollLeft = scrollWhenCanvasIsHidden.current.scrollLeft + -movementX;
scrollWhenCanvasIsHidden.current.scrollLeft = scrollLeft < 0 ? 0 : scrollLeft;
}
}
if (Math.abs(currentTransform[4]) > maxXMovementAllowed) {
// set to maxXmovementAllowed
newTransform[4] = maxXMovementAllowed;
}
// For Y Axis
// Manages if document view is going out of bounds
if (
Math.abs(newTransform[5]) >= maxYMovementAllowed ||
// Don't allow movement of canvas from top more than view. It should always be minus i.e. movement down is allowed
newTransform[5] > 0 ||
// If Y is 0 and still there is scrollable parent container, then scroll that instead of moving the canvas.
// If scrollTop has reached end but Y is still 0, then we will allow scroll down by transforming the canvas, the condition below checks that
// scrollWhenCanvasIsHidden?.current.scrollHeight >= scrollWhenCanvasIsHidden.current.scrollTop + scrollWhenCanvasIsHidden.current.clientHeight
(currentTransform[5] === 0 &&
scrollWhenCanvasIsHidden?.current &&
scrollWhenCanvasIsHidden?.current.scrollTop > 0 &&
!(
scrollWhenCanvasIsHidden?.current.scrollHeight >=
scrollWhenCanvasIsHidden.current.scrollTop +
scrollWhenCanvasIsHidden.current.clientHeight
))
) {
newTransform[5] = currentTransform[5];
// if parent can scroll, then scroll it
if (scrollWhenCanvasIsHidden?.current) {
const scrollTop = scrollWhenCanvasIsHidden.current.scrollTop + -movementY;
scrollWhenCanvasIsHidden.current.scrollTop = scrollTop < 0 ? 0 : scrollTop;
}
}
canvas.setViewportTransform(newTransform);
}
};
canvas.on('mouse:down', (opt) => {
if (!viewPortInitialTransform.current) {
viewPortInitialTransform.current = canvas.viewportTransform;
}
isMouseDown.current = true;
if (opt.e instanceof TouchEvent) {
previousMouseDown.current = { x: opt.e.touches[0].screenX, y: opt.e.touches[0].screenY };
} else {
previousMouseDown.current = { x: opt.e.screenX, y: opt.e.screenY };
}
});
canvas.on('mouse:up', (opt) => {
isMouseDown.current = false;
});
// scroll and zoom on wheel
canvas.on('mouse:wheel', (opt) => {
if (!viewPortInitialTransform.current) {
viewPortInitialTransform.current = canvas.viewportTransform;
}
const delta = opt.e.deltaY * 4;
if (opt.e.ctrlKey) {
zoomIng(delta, opt.e.offsetX, opt.e.offsetY, opt.e);
} else {
// scroll
transformMove(-opt.e.deltaX, -delta);
}
opt.e.preventDefault();
opt.e.stopPropagation();
});
const zoomIng = (
delta: number,
offsetX: number,
offsetY: number,
e: any,
zoomFromGesture?: number
) => {
// zoom
const zoom = canvas.getZoom();
let newZoom = zoomFromGesture || zoom * 0.999 ** delta;
const canvasWidth = canvas.getWidth();
// we are limiting the zoom to max 5000px
// It's upto you if you want it to be limited.
if (canvasWidth * newZoom > 5000) {
return;
}
// don't allow to go below zoom 1
if (newZoom < 1) {
newZoom = 1;
}
if (zoom === newZoom) {
if (e?.preventDefault) {
e.preventDefault();
}
if (e?.stopPropagation) {
e.stopPropagation();
}
return;
}
// zoom points
const tranformBeforeZoom = canvas.viewportTransform;
if (!tranformBeforeZoom) {
return;
}
// zoom View Port Transformation (VPT) implementation is from fabric.zoomToPoint function
// we don't want to set mutliple VPT for the same function run.
const before = new fabric.Point(offsetX, offsetY);
const currentTransform = [...tranformBeforeZoom];
const newPoint = fabric.util.transformPoint(
before,
fabric.util.invertTransform(tranformBeforeZoom)
);
currentTransform[0] = newZoom;
currentTransform[3] = newZoom;
const after = fabric.util.transformPoint(newPoint, currentTransform);
currentTransform[4] += before.x - after.x;
currentTransform[5] += before.y - after.y;
// if canvas items are going out of bounds from any of the corner, then transform
if (viewPortInitialTransform.current && currentTransform && newZoom < zoom) {
// X axis movement
const xShiftingAllowed = widthAndHeight.width * newZoom - widthAndHeight.width;
if (xShiftingAllowed > 0 && Math.abs(currentTransform[4]) > xShiftingAllowed) {
currentTransform[4] = currentTransform[4] > 0 ? xShiftingAllowed : -xShiftingAllowed;
}
if (xShiftingAllowed <= 0) {
currentTransform[4] = viewPortInitialTransform.current[4];
}
// Y axis movement
const yShiftingAllowed = totalHeight * newZoom - widthAndHeight.height;
if (yShiftingAllowed > 0 && Math.abs(currentTransform[5]) > yShiftingAllowed) {
currentTransform[5] = currentTransform[5] > 0 ? yShiftingAllowed : -yShiftingAllowed;
}
if (yShiftingAllowed <= 0) {
currentTransform[5] = viewPortInitialTransform.current[5];
}
}
canvas.setViewportTransform(currentTransform);
};
// Add touch event support
// from an answer on StackOverFlow. Tweaked according to need.
canvas.on('touch:gesture', (ev: { e: TouchEvent; self: any; target: any } & any) => {
if (ev.e.touches && ev.e.touches.length === 2) {
if (ev.self.state === 'start') {
zoomStartScale.current = canvas.getZoom();
zoomOffsetStart.current = { x: ev.self.x, y: ev.self.y };
}
const delta = zoomStartScale.current * ev.self.scale;
zoomIng(0, ev.self.x, ev.self.y, ev.e, delta);
}
});
}
);
};
There is always room for improvement. So, you can comment below.
To find some of the solutions you need to dig the library too. So be ready for that. e.g. in above code we have taken the zoom implementation from the lib itself, so that we can combine the transformation with our own logic.
Cheers and Peace out!!!