1. Infinite Canvas in Excalidraw

·

7 min read

Infinite Canvas

One of the features that makes Excalidraw stand out is its “infinite canvas”, which is a feature that allows users to continue expanding the canvas while the actual size, that is the width and the height, of the canvas element stays the same.

InfiniteCanvasInExcalidraw.gif

By simply scrolling left, right, up, or down, we get more space on the canvas to work with. This allows Excalidraw to exceed the limitations of physical whiteboards and makes it easy for users to work with large scenes.

How can we implement this? Let’s first take a look at a very simple solution in vanilla JS.

Simple Implementation of Infinite Canvas

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Infinite Canvas</title>
  </head>
  <body>
    <canvas id="canvas">Infinite Canvas</canvas>
    <script>
      const canvas = document.getElementById('canvas')
      canvas.width = window.innerWidth
      canvas.height = window.innerHeight

      const context = canvas.getContext('2d')

      let canvasScroll = {
        x: 0,
        y: 0
      }

      class Scene {
        elements = [
          {
            type: 'rect',
            x: canvas.width / 2,
            y: canvas.height / 2,
            width: 200,
            height: 200,
            color: 'rgba(55, 55, 10, 0.3)',
          },
          {
            type: 'rect',
            x: 100,
            y: 300,
            width: 200,
            height: 200,
            color: 'rgba(155, 200, 10, 0.5)',
          }
        ]

        updateScene (offset) {
          for (let element of this.elements) {
            element.x -= offset.x
            element.y -= offset.y
          }
          this.renderScene()
        }

        renderScene () {
          context.clearRect(0, 0, canvas.width, canvas.height)
          for (let element of this.elements) {
            if (element.type === 'rect') {
              context.fillStyle = element.color
              context.fillRect(
                element.x,
                element.y,
                element.width,
                element.height
              )
            } else {
              console.error(`type: ${element.type} is not supported`)
            }
          }
        }
      }

      const scene = new Scene()
      scene.renderScene()

      canvas.addEventListener('wheel', e => {
        e.preventDefault()
        canvasScroll.x = e.deltaX
        canvasScroll.y = e.deltaY
        scene.updateScene(canvasScroll)
      })
    </script>
  </body>
</html>

We attach a wheel event to canvas, which updates the canvasScroll object using deltaX and deltaY of wheel events. canvasScroll is then fed into scene.updateScene, which uses the offset to update each element’s position. After that, we simply render the elements using their new positions. Voila, we have created a simple infinite canvas.

InfiniteCanvasWithPureJS.gif

Infinite Canvas in Excalidraw

Of course, the implementation of infinite canvas in Excalidraw is more complex. However, the basic logic is roughly the same.

  • Canvas is attached a wheel event listener
  • This wheel event listener updates the scroll offsets
  • The scroll offsets are used to update each element’s position on the canvas when we render them

All right, let’s go.

Excalidraw has a type called SceneState.

export type SceneState = {
  scrollX: FlooredNumber;
  scrollY: FlooredNumber;
  // null indicates transparent bg
  viewBackgroundColor: string | null;
  zoom: number;
};

SceneState consists of current scroll offset, the background of the canvas, and the canvas zoom, which is represented by a number. The app state stores the scene state separately as individual properties. Then when value of type SceneState is expected, we can put these values together as an object of type SceneState.

For example, when we call renderScene, we need to provide a SceneState.

  componentDidUpdate() {
    const atLeastOneVisibleElement = renderScene(
      elements,
      this.state.selectionElement,
      this.rc!,
      this.canvas!,
      {
        scrollX: this.state.scrollX,
        scrollY: this.state.scrollY,
        viewBackgroundColor: this.state.viewBackgroundColor,
        zoom: this.state.zoom,
      },
      {
        renderOptimizations: true,
      },
    );
    // ...
  }

Scroll offset and zoom which are kept in app state are updated by event handlers.

Let’s take a look at handleWheel event handler, which is attached to the canvas element

public render() {
  // ...
  return (
    <div className="container">
      <LayerUI
        canvas={this.canvas}
        // ...
      />
      <main>
        <canvas
          id="canvas"
          ref={canvas => {
          if (canvas !== null) {
            this.canvas = canvas;
            this.rc = rough.canvas(this.canvas);
            this.canvas.addEventListener("wheel", this.handleWheel);
            // ...
          }
          // ...
          }}
          // ...
        />
      </main>
    </div>
  )
}

Here’s the definition of handleWheel

private handleWheel = (event: WheelEvent) => {
  event.preventDefault();
  const { deltaX, deltaY } = event;

  if (event[KEYS.META]) {
    const sign = Math.sign(deltaY);
    const MAX_STEP = 10;
    let delta = Math.abs(deltaY);
    if (delta > MAX_STEP) {
      delta = MAX_STEP;
    }
    delta *= sign;
    this.setState(({ zoom }) => ({
      zoom: getNormalizedZoom(zoom - delta / 100),
    }));
    return;
  }

  this.setState(({ zoom, scrollX, scrollY }) => ({
    scrollX: normalizeScroll(scrollX - deltaX / zoom),
    scrollY: normalizeScroll(scrollY - deltaY / zoom),
  }));
};

When the command key is pressed, we can control zooming via wheel events. Otherwise, we are setting scrollX and scrollY to app state.

  • normalizeScroll basically floors a number to get “normalization”.

A tiny detail worth noting here is that scrollX and scrollY stores the accumulated scroll amount

this.setState(({ zoom, scrollX, scrollY }) => ({
  scrollX: normalizeScroll(**scrollX - deltaX / zoom**),
  scrollY: normalizeScroll(**scrollY - deltaY / zoom**),
}));

When app state changes, componentDidUpdate will be called to trigger renderScene.

componentDidUpdate() {
  const atLeastOneVisibleElement = renderScene(
    elements,
    this.state.selectionElement,
    this.rc!,
    this.canvas!,
    {
      scrollX: this.state.scrollX,
      scrollY: this.state.scrollY,
      viewBackgroundColor: this.state.viewBackgroundColor,
      zoom: this.state.zoom,
    },
    {
      renderOptimizations: true,
    },
  );
  const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0;
  if (this.state.scrolledOutside !== scrolledOutside) {
    this.setState({ scrolledOutside: scrolledOutside });
  }
  this.saveDebounced();
  if (history.isRecording()) {
    history.pushEntry(this.state, elements);
    history.skipRecording();
  }
}

Here’s the relevant bits of renderScene

export function renderScene(
  elements: readonly ExcalidrawElement[],
  selectionElement: ExcalidrawElement | null,
  rc: RoughCanvas,
  canvas: HTMLCanvasElement,
  sceneState: SceneState,
  // extra options, currently passed by export helper
  {
    renderScrollbars = true,
    renderSelection = true,
    // Whether to employ render optimizations to improve performance.
    // Should not be turned on for export operations and similar, because it
    //  doesn't guarantee pixel-perfect output.
    renderOptimizations = false,
  }: {
    renderScrollbars?: boolean;
    renderSelection?: boolean;
    renderOptimizations?: boolean;
  } = {},
): boolean {
    // ...

    const visibleElements = elements.filter(element =>
    isVisibleElement(
      element,
      normalizedCanvasWidth,
      normalizedCanvasHeight,
      sceneState,
    ),
  );

    applyZoom(context);
  visibleElements.forEach(element => {
    renderElement(element, rc, context, renderOptimizations, sceneState);
  });
  resetZoom(context);

    // ...
}

As we scroll around, some elements might be out of scene. For those are not visible, there’s no need to render them. For each visible element, we call renderElement to render it, again providing the necessary parameters including SceneState.

export function renderElement(
  element: ExcalidrawElement,
  rc: RoughCanvas,
  context: CanvasRenderingContext2D,
  renderOptimizations: boolean,
  sceneState: SceneState,
) {
  const generator = rc.generator;
  switch (element.type) {
    case "selection": {
      context.translate(
        element.x + sceneState.scrollX,
        element.y + sceneState.scrollY,
      );
      const fillStyle = context.fillStyle;
      context.fillStyle = "rgba(0, 0, 255, 0.10)";
      context.fillRect(0, 0, element.width, element.height);
      context.fillStyle = fillStyle;
      break;
    }
    case "rectangle":
    case "diamond":
    case "ellipse":
    case "line":
    case "arrow":
    case "text": {
      generateElement(element, generator, sceneState);

      if (renderOptimizations) {
        drawElementFromCanvas(element, rc, context, sceneState);
      } else {
        const offsetX = Math.floor(element.x + sceneState.scrollX);
        const offsetY = Math.floor(element.y + sceneState.scrollY);
        context.translate(offsetX, offsetY);
        drawElementOnCanvas(element, rc, context);
        context.translate(-offsetX, -offsetY);
      }
      break;
    }
    default: {
      throw new Error(`Unimplemented type ${element.type}`);
    }
  }
}

For element that’s not a selection element, we first call generateElement, which uses rough’s generator to generate the given element’s shape if it hasn’t been generated already. In addition, generateElement also generates the element’s canvas. We’ll talk more about this in render optimizations.

If renderOptimizations flag is turned on, we will call drawElementFromCanvas to render the element. In either case, we first translate the canvas origin, then we render the element, and then we translate the origin back to where it was.

export function renderElement(
  element: ExcalidrawElement,
  rc: RoughCanvas,
  context: CanvasRenderingContext2D,
  renderOptimizations: boolean,
  sceneState: SceneState,
) {
    // ...
      generateElement(element, generator, sceneState);

      if (renderOptimizations) {
        drawElementFromCanvas(element, rc, context, sceneState);
      } else {
        const offsetX = Math.floor(element.x + sceneState.scrollX);
        const offsetY = Math.floor(element.y + sceneState.scrollY);
        context.translate(offsetX, offsetY);
        drawElementOnCanvas(element, rc, context);
        context.translate(-offsetX, -offsetY);
      }
    // ...
}

That’s it! Excalidraw uses an accumulated scrollX and scrollY to translate the context. This way, we are never updating the element’s x and y positions when we scroll around. x and y are only updated when we actually move the element around.

As a reference, we can update the simple infinite canvas implementation as follows

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Infinite Canvas</title>
    <style>
    </style>
  </head>
  <body>
    <canvas id="canvas">Infinite Canvas</canvas>
    <script>
      const canvas = document.getElementById('canvas')
      canvas.width = window.innerWidth
      canvas.height = window.innerHeight

      const context = canvas.getContext('2d')

      let canvasScroll = {
        x: 0,
        y: 0
      }

      class Scene {
        elements = [
          {
            type: 'rect',
            x: 150,
            y: 150,
            width: 200,
            height: 200,
            color: 'rgba(55, 55, 10, 0.3)',
          },
          {
            type: 'rect',
            x: 250,
            y: 300,
            width: 200,
            height: 200,
            color: 'rgba(155, 200, 10, 0.5)',
          }
        ]

        renderScene (offset) {
          context.clearRect(0, 0, canvas.width, canvas.height)
          for (let element of this.elements) {
            if (element.type === 'rect') {
              const offsetX = Math.floor(element.x + offset.x)
              const offsetY = Math.floor(element.x + offset.y)
              context.translate(offsetX, offsetY)
              context.fillStyle = element.color
              context.fillRect(
                element.x,
                element.y,
                element.width,
                element.height
              )
              context.translate(-offsetX, -offsetY)
            } else {
              console.error(`type: ${element.type} is not supported`)
            }
          }
        }
      }

      const scene = new Scene()
      scene.renderScene(canvasScroll)

      canvas.addEventListener('wheel', e => {
        e.preventDefault()
        canvasScroll.x -= e.deltaX
        canvasScroll.y -= e.deltaY
        scene.renderScene(canvasScroll)
      })
    </script>
  </body>
</html>