I’m trying to create an infinite pinboard Javascript app, similar to Miro or Figma, where users can zoom in and out using the wheel event, pan left/right/up/down by dragging from a blank spot on the workspace, and move individual elements anywhere on the workspace by dragging and dropping them. I’m using React without JSX. No HTML canvas.
I’ve got each of these functionalities working, except for zooming while centered around the cursor. (Rather than zooming relative to the static (0,0) origin point of the workspace element.)
The closest I’ve gotten to it working is transforming the origin of the zoomable element (#workspace) to the current mouse position when the wheel event fires. This mostly works, but when you are at any zoom scale other than 1.0, if you move your cursor to a different position and then zoom again, the entire workspace jumps and what’s under your cursor has changed (it should stay the same, just appear at a different zoom scale). Then when you zoom again, without moving the mouse, it behaves as desired, with what’s under your cursor staying put, and just getting bigger or smaller.
The “jumping” behavior is more pronounced the further the current zoom scale is from 1.0. (An easy way to see it is to zoom in or out fairly far, move the cursor, then zoom again and watch the workspace contents move out from under you.) That makes me think that maybe I need to make an equation for the new transformOrigin values that in some way incorporates the zoom value. But I’m not sure how to write that equation. Nothing I’ve tried has worked.
Current code is here: https://codepen.io/swelmel/pen/zYeNpPO
Pulling out key functions:
Here’s my makeZoomable() function driving this behavior:
/*
* Make workspace zoomable, capturing wheel events for the whole document.
* Pass in appState.workspace for the state. Will change the 'zoom' prop.
* FIXME: Trying to center the zoom around the current mouse position,
* but it isn't working.
*/
function makeZoomable(state, el) {
console.log(`Making element zoomable:`,el);
function wheel(event) {
event.preventDefault();
event.stopPropagation();
// Move the zoomable element's origin to be at the current cursor pos
// Doesn't work -- when you move cursor and then zoom from a scale other
// than zoom=1.0, it'll jump around. Not sure why.
state.transformOrigin.x = event.clientX;
state.transformOrigin.y = event.clientY;
// Zoom in or out based on the scroll direction
const currentZoom = state.zoom;
let newZoom = currentZoom + ( event.deltaY * -(state.zoomRate) );
// Restrict scale
newZoom = restrictRange(newZoom,0.1,10);
state.zoom = newZoom;
renderUI();
};
document.addEventListener('wheel', wheel, { passive: false });
}
I’m passing the #workspace div to makeZoomable(), along with my React app state object, which stores the transformOrigin and zoom values, which are then passed as props to the render function for the Workspace React component (below). The workspace is re-drawn every time we run renderUI(), which we do whenever the state changes.
Note that the wheel event has to be on the entire document, rather than just the zoomable element, because the zoomable workspace element size is 0x0 and therefore couldn’t capture any wheel events. I have it set to 0x0 because we’re trying to simulate an infinite workspace, so we can’t prescribe a size for it. That means that the event.pageX/Y values are relative to the document rather than the zoomable element. That could be a source of the problem or why similar answers aren’t working for me but when I tried to offset it it didn’t work.
The Workspace React component:
function Workspace(props) {
const workspace = document.getElementById("workspace");
const { zoom, translate, mouse, transformOrigin } = props.workspace;
return e('div', {id: 'workspace', style: {
// To change the origin only when you zoom in/out:
transformOrigin: `${transformOrigin.x}px ${transformOrigin.y}px`,
// To change the origin whenever the mouse moves:
//transformOrigin: `${mouse.x}px ${mouse.y}px`,
transform: `scale(${zoom}) translate(${translate.x}px, ${translate.y}px)`
}},
e(Crosshairs, {x: mouse.x - translate.x, y: mouse.y - translate.y }),
//e(Crosshairs, {x: transformOrigin.x, y: transformOrigin.y }),
props.scraps.map((scrap) => e(Scrap, {key: scrap.id, scrap}))
);
}
The translate values in the transform CSS property are used for the panning functionality.
Any pointers would be greatly appreciated! I’ve been banging my head on this for a while.