Technology

Integrate Fabric.js canvas drawing library with React app via uncontrolled component

Piotr Tomczyk

Introduction

In this post, I will show you how to solve the problem I’ve been struggling within one of my recent projects. I wish I knew that before I started working on the project because that would save me a lot of time and a lot of headaches I had. Straight to the point, I’ll show you how to incorporate Fabric.js with React to benefit from both tools and without sacrificing much from any of the two.

The thing I’m going to implement is an uncontrolled React component that exposes a simple interface to Fabric canvas. We’re going to store options for each drawable object outside of the component, which will let us keep it in Redux, localStorage, or synchronized with the backend (or any other combination).

Create <Canvas> Component

We won’t be able to start without the foundation of Fabric.js which is the <canvas> element. We need to create one and attach it to the webpage just like any other HTML element. Once it’s done, we need to pass it to the new Fabric()constructor and we’re almost done. Almost, because we’d also like to keep it in our React component’s state to pass it down the tree. To do so, we’ll use useState hook, as described below.

import React, { useRef, useEffect } from 'react';
import { fabric } from 'fabric';

export function Canvas({
  setCanvas,
  children,
}: {
  setCanvas: (canvas: fabric.Canvas) => void;
  children?: React.ReactNode | React.ReactNodeArray;
}) {
  const canvasRef = useRef(null);

  useEffect(() => {
    setCanvas(
      new fabric.Canvas(canvasRef.current, {
        renderOnAddRemove: true,
      }),
    );
  }, [setCanvas]);

  return (
    <>
      <canvas ref={canvasRef}></canvas>
      {children}
    </>
  );
}

export function App() {
  const [canvas, setCanvas] = useState<fabric.Canvas | undefined>();
  return <Canvas setCanvas={setCanvas} />;
}

Declare the Interface

The next thing we’re going to implement is a fabric.Textbox element. The name is going to be as simple as possible, let’s say <Text />. It won’t have any children but it needs to have options. I mean <Text options={options} /> and not <Text {…options} /> because this is the easiest way to avoid unnecessary re-renders. Spreading the object will create a new object for each render cycle, forcing another render to happen.

One more thing our component will need is a canvas reference. I know that fabric.IObjectOptions already has canvas property, but I don’t think it will be a good idea to link those two at the moment, let’s keep options independent from the DOM.

We’ll also add the texts object to the outer component’s state. This object will have a string as key and fabric.ITextboxOptions as value. Each value represents fabric. The textbox has drawn on the fabric. Canvas. Another handy thing to have is a function that, when given a key and value, will replace the specific value of texts object. This is the onTextChange function that will be executed each time Fabric detects that its objects have changed. We declare those in the component that renders <Canvas>. To be sure that inside the <Text> we don’t need to handle the case when the canvas is not defined, we also add condition before rendering Text.

const [texts, setTexts] = useState<{ [key in string]: fabric.ITextboxOptions }>({
  '0': { text: 'A', left: 0 },
  '1': { text: 'B', left: 30 },
  '2': { text: 'C', left: 60 },
});

const onTextChange = useCallback((id: string, options: fabric.ITextOptions) => {
  setTexts((texts) => ({ ...texts, [id]: options }));
}, []);

return (
  <Canvas setCanvas={setCanvas}>
    {Object.entries(texts).map(
      ([key, options]) =>
        canvas && <Text id={key} options={options} canvas={canvas} onChange={onTextChange} key={key} />,
    )}
  </Canvas>
);

Now, let’s define the <Text /> component. We can create a new fabric. Textbox object right away. It’s worth noting that we wrap this in function, which means that it will be executed only once and then reused. Later, in useEffect hook, we add this object to canvas, which means that we render it on the <canvas> element controlled by Fabric.js. In the next hook, we apply options to the textbox, whenever the options object changes. This will make our textbox bound to options defined and modified outside of the component. But the object itself can also be edited by the user interacting with the canvas directly. To make a full loop, we also add an event listener to propagate each change using onTextChange listener defined earlier.

import React, { useState, useEffect } from 'react';
import { fabric } from 'fabric';

interface ITextProps {
  id: string;
  options: fabric.ITextboxOptions;
  canvas: fabric.Canvas;
  onChange: (id: string, options: fabric.ITextboxOptions) => void;
}
export function Text({ onChange, id, canvas, options }: ITextProps) {
  const [textbox] = useState<fabric.Textbox>(() => new fabric.Textbox(options.text ?? '', options));

  useEffect(() => {
    canvas.add(textbox);
  }, [canvas, textbox]);

  useEffect(() => {
    textbox.setOptions(options);
  }, [options, textbox]);

  useEffect(() => {
    const update = () => {
      onChange(id, textbox.toObject());
    };
    textbox.on('moved', update);
    textbox.on('scaled', update);
    textbox.on('rotated', update);
    textbox.on('changed', update);
  }, [id, onChange, textbox]);

  return <></>;
}

Declare another Interface

So far, so good. But can we do anything better? How about adding something more demanding than simple text. Like fabric.Image for example. It’s not so simple as one might think, we cannot just create new fabric.Image. Due to its asynchronous behavior, Fabric provides two factory methods to help us. One requires HTMLImageElement and the second one is using image URL as input.

Before we begin, I want to point out a few things. First, we already did a basic setup that can be used for Images as well and I don’t think that copy-pasting it for each Fabric component is a good idea. Second, we don’t have much “react logic” in our <Text>. We use a handful of hooks and that’s it. I believe it would be good to extract the common part and for eachfabric.Object subclass, Textbox, and Image in our example, we’ll just handle differences.

Let’s create our custom hook first. The problem we need to solve is that we used fabric.Textbox and fabric.ITextboxOptions which will not be useful for fabric.Image and its fabric.IImageOptions. Do they have anything in common? Sure, both inherit from fabric.IObjectOptions and fabric.IObjectOptions respectively. One good way to tie them is to use the hook with generic types restricted to those two common interfaces. We easily end up with something like below.

We’re one step closer to implementing fabric. Image, but the problem is that we need another upgrade. As mentioned earlier, the fabric. The image is created asynchronously. We need to have an async factory. Given the nature of Promises, at some point, we’ll have an element = undefined. Just to be sure we won’t use undefined as an object, we’ll add optional chaining operators (?.) to keep things simple. One more thing: we want to create an object only once, hence the if (element) check in the first useEffect that will early exit if it’s already happened before.

export function useFabricObject<T extends fabric.Object, O extends fabric.IObjectOptions>(
  objectFactory: (options: O) => Promise<T>,
  canvas: fabric.Canvas,
  id: string,
  options: O,
  onChange: (id: string, options: O) => void,
): T | undefined {
  const [element, setElement] = useState<T | undefined>();

  useEffect(() => {
    if (element) {
      return;
    }
    const setupObject = async () => {
      const awaitedElement = await objectFactory(options);
      canvas.add(awaitedElement);
      setElement(awaitedElement);
    };
    setupObject();
  }, [canvas, element, objectFactory, options]);

  useEffect(() => {
    const update = () => {
      onChange(id, element?.toObject());
    };
    element?.on('moved', update);
    element?.on('scaled', update);
    element?.on('rotated', update);
  }, [element, id, onChange]);

  useEffect(() => {
    element?.setOptions(options);
  }, [element, options]);

  return element;
}

const textboxFactory = async (options: fabric.ITextboxOptions): Promise<fabric.Textbox> => {
  return new fabric.Textbox(options.text ?? '', options);
};

interface ITextProps extends fabric.ITextboxOptions {
  id: string;
  options: fabric.ITextboxOptions;
  canvas: fabric.Canvas;
  onChange: (id: string, options: fabric.ITextboxOptions) => void;
}
export const Text = ({ canvas, id, options, onChange }: ITextProps) => {
  const factory = useCallback(() => textboxFactory(options), []);
  const textbox = useFabricObject(factory, canvas, id, options, onChange);

  useEffect(() => {
    const update = () => {
      onChange(id, textbox?.toObject());
    };
    textbox?.on('changed', update);
  }, [textbox, id, onChange]);

  return <></>;
};

We have useFabricObject where we no longer refer to the textbox. Instead, we use some abstract thing we labeled as an element. Another important thing is that we removed the ‘changed’ event as it is specific to texts and not other objects like images. This event is triggered when the user modifies text inside the textbox. We moved the handler to the <Text>component.

To keep the function signature consistent we can pass URL as options.data.src, where data is a property that can be used for any purpose. We also extracted the textboxFactory because constructors of fabric objects don’t necessarily share the same signature, which we’ll see in the code below, where you can see how <Image> is implemented.

const imageFactory = (options: fabric.IImageOptions): Promise<fabric.Image> => {
  return new Promise((resolve, reject) =>
    fabric.Image.fromURL(
      options.data?.src,
      (image) => {
        if (image) {
          return resolve(image);
        }

        return reject(image);
      },
      options,
    ),
  );
};

interface IImageProps extends fabric.IImageOptions {
  id: string;
  options: fabric.ITextboxOptions;
  canvas: fabric.Canvas;
  onChange: (id: string, options: fabric.ITextboxOptions) => void;
}
export const Image = (props: IImageProps) => {
  const factory = useCallback(() => imageFactory(props.options), []);
  useFabricObject(factory, props.canvas, props.id, props.options, props.onChange);

  return <></>;
};

Now, let’s make use of our new fancy components. The first thing I’d like to suggest is to create a new hook that will enclose both storing and updating objects’ options. This hook will be used for texts and images, the only issue we want to address is they use different types with the common parent. That’s the problem we’ve already solved, so it shouldn’t be too hard to figure it out again. We’ll also add another component to the canvas. We end up with something like below. (Note that we use inline SVG encoded as base64 instead of external URL, Fabric.js can handle that easily.)

import React, { useState } from 'react';

import { Canvas } from './canvas';
import { Image } from './image';
import { Text } from './text';

const useFabricData = <O extends fabric.IObjectOptions>(
  initial: { [key in string]: O },
): [{ [key in string]: O }, (id: string, options: O) => void] => {
  const [objects, setObjects] = useState<{ [key in string]: O }>(initial);

  const onObjectChange = useCallback((id: string, options: O) => {
    setObjects((objects) => ({ ...objects, [id]: options }));
  }, []);

  return [objects, onObjectChange];
};

const svg = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg">
  <g>
    <circle r="25" cy="25" cx="25" />
  </g>
</svg>`;
const base64svg = `data:image/svg+xml;base64,${btoa(svg)}`;

export function App() {
  const [canvas, setCanvas] = useState<fabric.Canvas | undefined>();

  const [texts, onTextChange] = useFabricData<fabric.ITextboxOptions>({
    '0': { text: 'A', left: 0 },
    '1': { text: 'B', left: 30 },
    '2': { text: 'C', left: 60 },
  });

  const [images, onImageChange] = useFabricData<fabric.IImageOptions>({
    '0': { left: 100, width: 50, height: 50, data: { src: base64svg } },
    '1': { left: 150, width: 50, height: 50, data: { src: base64svg } },
  });

  return (
    <Canvas setCanvas={setCanvas}>
      {Object.entries(texts).map(
        ([key, options]) =>
          canvas && <Text options={options} canvas={canvas} id={key} key={key} onChange={onTextChange} />,
      )}
      {Object.entries(images).map(
        ([key, options]) =>
          canvas && <Image options={options} canvas={canvas} id={key} key={key} onChange={onImageChange} />,
      )}
    </Canvas>
  );
}

Summary

In this post, I showed you one of many ways of how to wrap a complex library into an uncontrolled React component and still have something that is easily usable and extensible. I tried to follow the DRY principle where I found it appropriate and with the use of Typescript generics, I hope that it will be open for the implementation of other Fabric components as well.

You may also be interested in...

5 secrets of Swift API design

It was a stormy afternoon somewhere in Poland. In the town center, amid heavy rain, one could see a single window with a light on. In that […]

Igor Dąbrowski

Let's bring your project to life

Request a consultation