Enhancing FabricJS UX: Implementation with custom image URLs [Step-by-Step Tutorial]
Learn how to solve the problem of asynchronously loading images in your FabricJS application
Intro
I worked on FabricJS for enough time (during 2020β21) that itβs safe for me to say that I now have enough understanding of this library that I can play around with it and bring out interesting UX as well as DX (we developers too deserve to have an immersive experience, like our end-users). This is going to be the beginning of my article journey, and I hope to write many more articles around some of the interesting code implementations of JS libraries or even VanillaJS that I did in the months to come.
Prologue
In this article, I am going to talk about how we can show a spinner for each image added using a public URL while it is being loaded on the canvas (an instance of fabric.Canvas
). Sounds easy, right? It is. But itβs the edge cases that come crawling up in production.
Let us take a look at the UX problem we are trying to solve. (Take a closer look at the metrics block.)
The problem & The "obvious" solution
The problem: When different objects (of elements) are created on the canvas, they take only a little to no time (< 50 ms). But this is not the case with an image. More time would mean a laggy UX because, until then, the user wouldn't know what to expect or do. An externally linked image can take time, depending upon the network speed, compression, etc. The UI is also a bit of a problem, but we'll let CSS take care of that once we solve the problem that CSS cannot solve β JavaScript!
The obvious solution: LOADER!!
All we need now is to implement this loader.
This is how I'm adding an object to the canvas. β¬
// method defined in CustomCanvas model
handleAddElement(elemType: ElemTypes, options: AddElementOptionsParam, insertOptions?: InsertOptions) {
return new Promise<fabric.Object>((resolve, reject) => {
let element: fabric.Object | null = null;
const self = this;
const add = (obj: fabric.Object) => {
updateObjectBounds(obj, self._canvas);
if (isEmpty(insertOptions)) {
self._canvas.add(obj);
} else {
self._canvas.insertAt(obj, insertOptions!.insertAt, insertOptions!.replace);
}
resolve(obj);
};
if (isNil(options.top)) {
options.top = getRandomPosition(self._canvas.getHeight() / 2, 10);
}
if (isNil(options.left)) {
options.left = getRandomPosition(self._canvas.getWidth() / 2, 0);
}
switch (elemType) {
case ElemTypes.Image:
new Image({
src: options.src,
callback: add,
});
break;
case ElemTypes.Label:
element = new Label(options);
break;
case ElemTypes.Line:
element = new Line(options);
break;
case ElemTypes.BentLine:
element = new BentLine(options);
break;
case ElemTypes.Circle:
element = new Circle(options);
break;
case ElemTypes.NamurIcon:
element = new NamurIcon(options);
break;
default:
break;
}
if (element) {
add(element);
}
});
}
If you see the above code, the image element seems to work with a callback. All the remaining elements are being added synchronously.
Things we need to do now:
Create a loader object instance
Before the image is downloaded and fabric-js creates the
fabric.Image
object instance, insert a loader in place of the imageRemove the loader once the image is added to the canvas
Let's do it step-by-step.
The implementation
Step 1: Creating a loader
Instead of keeping a simple loader image, we will animate it to let the user know that something is coming up. β¬
interface LoaderOptions {
top: number;
left: number;
}
export const createLoader = (canvas: fabric.Canvas, { top, left }: LoaderOptions) => {
return new Promise<fabric.Image>((resolve, reject) => {
fabric.Image.fromURL(
loadingIndicator, // data URL imported from assets
(img) => { // the callback
resolve(img);
canvas.add(img);
img.animate("angle", 360, {
from: 0,
duration: 3000,
onChange: () => canvas.requestRenderAll(),
});
},
{ // fabric.Image options
// loader size is 16 x 16 in this case and the origin is set to center
top: top + 8,
left: left + 8,
// needed to rotate the loader image w.r.t central axis
originX: "center",
originY: "center",
// because we don't want the user to interact with this image
selectable: false,
// this will prevent the loader from getting exported with the canvas
excludeFromExport: true,
// this will help us differentiate the loader from other elements, if at all it will be needed
data: {
type: "loader",
}
}
);
});
};
Now, if we pass the correct parameters, this function will resolve with a reference to a loader object.
Step 2: Add the loader just before image loading starts
Adding an image from a URL is an asynchronous operation, so while the image loads, we will swiftly add our loader to the canvas.
Before β¬
export default class ImageModel {
constructor(mOptions?: ModelConstructor) {
if (!mOptions?.src) {
return;
}
fabric.Image.fromURL(
mOptions.src,
image => {
image.scaleToWidth(50);
mOptions.callback?.(image);
},
{
top: mOptions?.left ?? getRandomPosition(),
left: mOptions?.top ?? getRandomPosition(),
includeDefaultValues: false,
data: {
src: mOptions.src,
...getModelData(ElemTypes.Image, mOptions),
},
}
);
}
}
After integrating the loader object β¬
(read through the below code like someone is dictating that to you.)
handleAddElement(elemType: ElemTypes, options: AddElementOptionsParam, insertOptions?: InsertOptions) {
// rest of the code remains the same
case ElemTypes.Image:
new Image({
src: options.src,
callback: add,
canvas: self._canvas, // added reference to the canvas instance
});
break;
// rest of the code remains the same
}
export default class ImageModel {
constructor(mOptions?: ModelConstructor) {
if (!mOptions?.src) {
return;
}
const left = mOptions?.left ?? getRandomPosition();
const top = mOptions?.top ?? getRandomPosition();
if (mOptions?.canvas) {
createLoader(mOptions.canvas, { top, left })
.then(loader => {
// nothing to do here yet
});
}
fabric.Image.fromURL(
mOptions.src,
image => {
image.scaleToWidth(50);
mOptions.callback?.(image);
},
{
top,
left,
includeDefaultValues: false,
data: {
src: mOptions.src,
...getModelData(ElemTypes.Image, mOptions),
},
}
);
}
}
Did you understand what we did?
Step 3: Remove the loader once image loads
Since we only want to remove the loader specific to an image, there are two ways we can do this:
Method 1: Somehow attach the loader reference to the image instance, then use this loader reference to remove it from the canvas using canvasReference.remove(loaderReference)
making sure that only the image specific loader is removed
Method 2: Use the loader reference inside image creation callback (invoked when fabric.Image
instance is created) and remove it without attaching the reference to the image instance
Any more ideas?? Please do comment.
The main flaw of both of these methods is that they do not account for the asynchronous nature of the loader itself. Consider some of the what-ifs in retrospection once we go ahead with one of the above two methods.
What if the required image was cached and loaded even before the loader was rendered on the canvas? A.K.A., the edge case πͺ²
What if someone wanted to add the loader for some other element and achieve a similar behaviour?
What if there are different loaders that are visually different but should behave in a similar manner?
These what-ifs might not be applicable to every use case, but they would keep the code in line with some of the best coding practices.
Here is what I came up with: β¬
interface LoaderRef {
object: fabric.Object | null;
}
export default class ImageModel {
// rest of the code remains the same
let loaderRef: LoaderRef | undefined = undefined;
const left = mOptions?.left ?? getRandomPosition();
const top = mOptions?.top ?? getRandomPosition();
if (mOptions?.canvas) {
loaderRef = { object: null }; // π€« π
createLoader(mOptions.canvas, { top, left })
.then(loader => {
loaderRef!.object = loader;
});
}
fabric.Image.fromURL(
mOptions.src,
image => {
image.scaleToWidth(50);
mOptions.callback?.(image, loaderRef);
},
// rest of the code remains the same
);
}
}
handleAddElement(elemType: ElemTypes, options: AddElementOptionsParam, insertOptions?: InsertOptions) {
// rest of the code remains the same
const add = (obj: fabric.Object, loaderRef?: LoaderRef) => { // added an optional parameter
// rest of the code remains the same
if (shouldShowLoaderFor(a_string_to_identify_the_type_of_element) && loaderRef) {
removeImageLoader(self._canvas, loaderRef);
}
};
// rest of the code remains the same
}
function removeImageLoader(canvas: fabric.Canvas, ref: LoaderRef, attempt: number = 0) {
if (ref.object) {
canvas.remove(ref.object);
} else if (attempt < MAX_ATTEMPTS) {
setTimeout(removeImageLoader, 70/*Why not a different time?*/, ref, attempt + 1);
}
}
Let's see our changes in action.
(see how a rounded and rotating arrow icon appears when I add image using a URL.)
It does need some polishing. You could use a better loader π.
Does it seem that the work is done? Nope, not yet. There is still 1 case left where we need to handle the images and loaders.
The initial loading β when a saved canvas is loaded on screen refresh.
How would you solve this? There are two options (again) here:
Show one single loader for the whole canvas and remove it once all the elements are loaded
Show individual loaders for specific elements as we did while adding them
Since the fabric.Canvas.loadFromJSON
method accepts a callback function as a parameter to execute when all of the objects (or elements) are loaded and initialized, both of the above two solutions will have the same effect.
But, because I like to show off π, I did attempt the tough nut (second solution) and was able to achieve individual loaders using the same concept shown above. Here is the code β¬
// part of CustomCanvas model
private _objectLoaders: Array<Promise<fabric.Object>>;
loadFromJSON(json: any, options: { successCallback?: Function }) {
this._loadFromJsonSuccess = false;
json?.objects?.forEach((objectJson: Object) => {
// notice that here we are not dealing with fabric instance
if (shouldShowLoaderFor(a_string_to_identify_the_type_of_element)) {
self._objectLoaders.push(
createLoader(
self._canvas,
{ top: objectJson.top, left: objectJson.left }
)
);
}
});
Promise.all(this._objectLoaders)
// Why not Promise.allSettled() instead of Promise.all()?
.then(loaders => {
function purgeLoaders() {
if (self._loadFromJsonSuccess) {
loaders.forEach(loader => self._canvas.remove(loader));
} else {
setTimeout(purgeLoaders, 70);
}
}
purgeLoaders();
})
.catch(e => {
// what would you do here?
});
this._canvas.loadFromJSON(
json,
() => {
self._loadFromJsonSuccess = true;
successCallback();
}
);
}
Thank you for reading. I hope it was useful. π€