Infinite workspace without Canvas
Tags: webdev, tutorial, react, html
Published: 2023-03-17 11:14:26

In the previous [article](https://dev.to/iscorekin/how-to-draw-beautiful-connections-with-svg-17ii), I talked about how we draw connections in [Flode](https://flode.it/) 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](https://ant.design/), 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`.

```html
<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>

```

```css
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:

```css
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:

```html
<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>
```


![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ybl6nzg91scyg5sajxu7.png)
*translate(0px, 0px)*


![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jf5ui34kky5l50trxvkw.png)
*translate(300px, 170px)*


![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jh0gyn7e7p2556iqjfq7.png)
*translate(-100px, -1000px)*



## 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:

1. `mouseDown` - start tracking cursor movements.
2. `mouseUp` - stop tracking cursor movements.
3. `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:

```js
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:

```js
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](https://codesandbox.io/s/pan-and-zoom-vupeh3?file=/src/styles.css) from examples