DEV Community

Cover image for Infinite workspace without Canvas
Nikita Mizev
Nikita Mizev

Posted on

Infinite workspace without Canvas

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>

Enter fullscreen mode Exit fullscreen mode
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;
}

Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode


translate(0px, 0px)


translate(300px, 170px)


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:

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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)