In the previous article, I talked about how we draw connections in Flode between nodes in our spaces. Now I'll tell you how our spaces themselves are implemented!
The working space in our application is an infinite board on which nodes can move. It is necessary to implement pan and zoom for it. We do all this without using Canvas, since the application is built on React, the design system uses antd, and nodes can be huge forms. Implementing such interfaces would be much more difficult if we didn't have access to native HTML-5 tools.
Workaround
If you have read the article about connections, you already have an idea of how DOM is structured. Let's go into more detail here. Everything is wrapped in .app
with position: relative
, as well as width and height set to 100%
. relative
is needed to control the divs with absolute positioning inside itself, and width and height are obviously used to occupy the entire screen. The other containers have similar styles, with the only difference being that the main container has overflow: hidden
.
<div class="app">
<div class="container">
<div class="nodes-container">
<div class="node">node #1</div>
<div class="node">node #2</div>
<div class="node">node #3</div>
</div>
</div>
</div>
html, body {
width: 100%;
height: 100%;
}
.app {
overflow: hidden;
width: 100%;
height: 100%;
position: relative;
}
.container, .nodes-container {
position: absolute;
width: 100%;
height: 100%;
top: 0px;
left: 0px;
}
.container {
overflow: hidden;
}
.node {
position: absolute;
}
To display the pan and zoom, it will be enough to use only one CSS property transform
with parameters in the form of two functions: translate
, which performs displacement along x
and y
by the given values, and scale
, which changes the size of the element by the given multiplier. An example that will move an element by 20px
on the x
axis, 40px
on the y
axis, and increase it by 2 times:
transform: translate(20px, 40px) scale(2);
This property will be applied to .nodes-container
. As mentioned earlier, all containers are the same size as the user's screen resolution. .container
has overflow: hidden
, so the native scroll will not appear, no matter how large the internal elements are. At the same time, .node
relative to .nodes-container
can have any position, including outside its bounds, and translate
has no restrictions. Thus, the effect of infinity is achieved, when .node
can be assigned any coordinates, and by shifting .nodes-container
, display it on the screen:
<div class="nodes-container" style="transform: translate(0px, 0px) scale(1);">
<div class="node" style="top: -20px; left: -60px;">node #1</div>
<div class="node" style="top: 230px; left: 150px;">node #2</div>
<div class="node" style="top: 330px; left: 350px;">node #3</div>
<div class="node" style="top: 1200px; left: 600px;">node #4</div>
</div>
Movement
Now we need to give the user the ability to control the offset. This will be implemented through drag-n-drop.
In further examples, React components will be used to shorten and provide more clarity, but all the techniques mentioned can be applied with other libraries as well as native JS.
The component will use two states: viewport
to store information about the current position and isDragging
to track when to capture cursor movements. viewport
contains offset
, an object of displacement along the x
and y
axes, and zoom
is the multiplier for scaling. Let's assume that by default the displacement is 0, and the zoom is 1.
We will need to track three events:
-
mouseDown
- start tracking cursor movements. -
mouseUp
- stop tracking cursor movements. -
mouseMove
- actually track movement.
The handlers of these events will be hanging on .app
to work in any part of the screen. The first two are clear, they simply change isDragging
when mouse button pressed and released. We will focus more on handleMouseMove
. Firstly, this event should trigger only when isDragging === true
. Secondly, if e.buttons !== 1
, that is, no button is pressed, isDragging
is changed to false
, and tracking is stopped. This is done so that if, for some reason, releasing the button was not detected by handleMouseUp
(for example, it was released on the address bar, outside the application), tracking the cursor movement would stop forcibly. Finally, if all checks are passed, viewport
is updated.
MouseEvent
provides properties movementX
and movementY
, which are the delta of cursor movement. It is sufficient to add this delta to the previous offset
. Thus, each time mouseMove
is triggered, viewport
will be updated, which, in turn, will change the transform
of .nodes-container
.
The entire component code:
export default function App() {
const [viewport, setViewport] = useState({
offset: {
x: 0.0,
y: 0.0
},
zoom: 1
});
const [isDragging, setIsDragging] = useState(false);
const handleMouseDown = () => {
setIsDragging(true);
};
const handleMouseUp = () => {
setIsDragging(false);
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging) {
return;
}
if (e.buttons !== 1) {
setIsDragging(false);
return;
}
setViewport((prev) => ({
...prev,
offset: {
x: prev.offset.x + e.movementX,
y: prev.offset.y + e.movementY
}
}));
};
return (
<div
className="app"
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMove}
>
<div className="container">
<div
className="nodes-container"
style={{
transform: `translate(${viewport.offset.x}px, ${viewport.offset.y}px) scale(${viewport.zoom})`
}}
>
{/* ... */}
</div>
</div>
</div>
);
}
Zoom
From a UX point of view, the optimal actions that a user should take to zoom are to hold down Ctrl and scroll the mouse wheel. Firstly, this is an established and understandable process. Secondly, it is supported and mimicked by many touchpads on laptops when detecting the "pinch" gesture.
Let's add another event listener to .app
using onwheel
. However, this time we will not use component props, but instead use a ref. This has an interesting explanation: if we attach a listener to an element using React, it acquires the passive: true
property, which breaks the entire logic because we need to use preventDefault()
. First, we will call it and stopPropagation()
to prevent zooming, which is implemented in the browser. We will also check if the ctrl key is pressed.
Next, we need to calculate the multiplier speedFactor
for the scroll delta. The thing is that its unit of measurement can be in pixels, lines, or pages, and we need to convert this delta to 0.2px per unit for maximum smoothness. WheelEvent.deltaMode
contains this information as an "unsigned long", and below I will provide a table according to which speedFactor
will be calculated:
Constant | Value | speedFactor |
---|---|---|
DOM_DELTA_PIXEL | 0x00 | 0.2 |
DOM_DELTA_LINE | 0x01 | 5 |
DOM_DELTA_PAGE | 0x02 | 10 |
Ultimately, pinchDelta
will be the key value for calculating the new zoom. It is the product of the negative delta and speedFactor
. It is negative to handle the correct directions of the mouse wheel movement.
It is also necessary to limit the approximation value so that users do not get carried away with pixel-perfect layout scrutiny. Let's take, for example, 0.1 as the lower limit and 1.3 as the upper limit. To maintain smoothness, the zoom
will increase exponentially, which means it will be multiplied by 2 to the power of pinchDelta
each time:
export default function App() {
const [viewport, setViewport] = useState({
offset: {
x: 0.0,
y: 0.0
},
zoom: 1
});
const [isDragging, setIsDragging] = useState(false);
const handleMouseDown = () => {
setIsDragging(true);
};
const handleMouseUp = () => {
setIsDragging(false);
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging) {
return;
}
if (e.buttons !== 1) {
setIsDragging(false);
return;
}
setViewport((prev) => ({
...prev,
offset: {
x: prev.offset.x + e.movementX,
y: prev.offset.y + e.movementY
}
}));
};
const handleWheel = (e: React.WheelEvent) => {
if (!e.ctrlKey) {
return;
}
e.preventDefault();
const delta = -e.deltaY / 1000;
const newZoom = Math.pow(2, Math.log2(viewport.zoom) + delta);
const minZoom = 0.1;
const maxZoom = 10;
if (newZoom < minZoom || newZoom > maxZoom) {
return;
}
setViewport((prev) => ({
...prev,
zoom: newZoom
}));
};
return (
<div
className="app"
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMove}
onWheel={handleWheel}
>
<div className="container">
<div
className="nodes-container"
style={{
transform: `translate(${viewport.offset.x}px, ${viewport.offset.y}px) scale(${viewport.zoom})`
}}
>
{/* ... */}
</div>
</div>
</div>
);
}
Good additions to this space would be support for scrolling to move both vertically and horizontally (which is very convenient for laptop users), touch support, and movement restriction based on extreme nodes. All of this can be easily added with a ready-made base. That's all, thank you for your attention!
Source from examples
Top comments (0)