Back then those days, I'm on my way to building a very large project myself, it's a social media app. And there is a very interesting feature that I want my app to have, yeah, it's the story feature where people can share things that will be automatically hidden after 24 hours. I decided to build a simpler version of it and today I want to share with you guys the experience of building a Facebook story creator.
Live demo: https://trunghieu99tt.github.io/Facebook-story-mini/
1. Scope
First, let's define our scope. The story feature in the Facebook app on mobile is a very huge feature which a lot of smaller features in it, but the story feature on the Facebook website is not.
On the website, we only have 2 options. 1 is text story and 2 is image story with texts. In this blog, I will go for the story feature on the Facebook website which is much more simpler I think.
Okay, let's go a little bit further and see what we have to do
- Text story: a paragraph in the middle and a changeable background
- Image story: one image per story and we also can add text blocks
It seems to be simple, right? at least with the text story feature. Okay, let's move to the next part
2. Tools, libraries
I use ReactJS to develop this feature, with text story it's enough, but with image story, we need to find a library that helps us deal with add/remove text blocks, change direction, size,... and I came up with Fabric Fabric provides interactive object model on top of the canvas element, that's exactly what we want to do here. I think you'd better go to the Fabric website and read about it before continue reading.
3. Start coding
You can use whatever boilerplate you want, to me, I will stick with Create React App. I will assume that you guys have basic knowledge of React and know how to create and run a React App. Another small note is that in this project, I will use Typescript but I think people don't know about Typescript, it's not a big deal because it's just a small project.
In this project, we will need to add 2 more packages: fabric and fabricjs-react (actually we don't need this package, but to make things easier, it's okay to use).
Run this command:
yarn add fabric fabricjs-react
#or
npm install fabric fabricjs-react
Okay, now we're good to go.
Before going to the next step, let's define our folder structure, we know that we have 2 main types of components: 1 is story form to create text or image story, 2 is viewer components where we show the data from the server after creating and save text/image story. I'll create a folder structure like this:
constants folder will hold all the constant values we use in this app.
3.1. Text story
About text story, it's the easier one, we just have a div and text in the center of that div. we can also change the background of that div.
In StoryForm, create a folder called Text, in that folder, create 3 files: index.ts (our entry file), textStory.module.css, and TextStory.tsx.
In TextStory.tsx:
import { ChangeEvent, useState } from "react";
import { BACKGROUND_LIST } from "../../../constants";
import classes from "./textStory.module.css";
const TextStory = () => {
const [text, setText] = useState("");
const [background, setBackground] = useState("#000");
const onChangeText = (e: ChangeEvent<HTMLTextAreaElement>) => {
const text = e.target.value;
setText(text);
};
const saveToServer = () => {
const data = {
type: "text",
background,
text,
};
localStorage.setItem("data", JSON.stringify(data));
};
return (
<div className={classes.root}>
<aside className={classes.aside}>
<textarea
className={classes.textarea}
onChange={onChangeText}
rows={7}
/>
<p>Change color</p>
<ul className={classes.backgroundList}>
{BACKGROUND_LIST.map((color) => {
return (
<li
onClick={() => setBackground(color)}
style={{
background: color,
cursor: "pointer",
outline: `${
color === background
? "2px solid blue"
: ""
} `,
}}
></li>
);
})}
</ul>
<button onClick={saveToServer}>Save</button>
</aside>
<div
className={classes.main}
style={{
background: background,
}}
>
<p className={classes.text}>{text}</p>
</div>
</div>
);
};
export default TextStory;
Above is the full code for that component. We have a state to store our text and a state to store the background color. About the saveToServer function, you can ignore it, we will go back to it later on this blog. With background color list, in this project, we will hardcode it (but you can change it to a color picker or whatever you want to make it better)
Create an index.ts file in the constants folder and put this to it:
export const BACKGROUND_LIST = [
'linear-gradient(138deg, rgba(168,74,217,1) 0%, rgba(202,88,186,1) 55%, rgba(229,83,128,1) 100%)',
'linear-gradient(138deg, rgba(55,31,68,1) 0%, rgba(115,88,202,1) 55%, rgba(97,0,30,1) 100%)',
'linear-gradient(138deg, rgba(31,68,64,1) 0%, rgba(202,88,155,1) 55%, rgba(90,97,0,1) 100%)',
'linear-gradient(138deg, rgba(14,33,240,1) 0%, rgba(88,202,197,1) 55%, rgba(11,97,38,1) 100%)',
'radial-gradient(circle, rgba(238,174,202,1) 0%, rgba(148,187,233,1) 100%)',
'linear-gradient(138deg, rgba(14,33,240,1) 0%, rgba(88,202,197,1) 55%, rgba(11,97,38,1) 100%)',
'radial-gradient(circle, rgba(198,76,129,1) 12%, rgba(218,177,209,1) 27%, rgba(148,187,233,1) 100%',
'linear-gradient(180deg, rgba(62,66,105,1) 0%, rgba(233,225,107,1) 55%, rgba(11,97,38,1) 100%)',
'radial-gradient(circle, rgba(117,67,81,1) 2%, rgba(107,233,164,1) 37%, rgba(97,11,11,1) 100%)',
'#2d88ff',
'#ececec',
'#6344ed',
'#8bd9ff',
'linear-gradient(315deg, rgba(255,184,0,1) 0%, rgba(237,68,77,0.7175245098039216) 61%, rgba(232,68,237,1) 78%)',
];
About the style file, it's a little bit long so I won't post it here. But I'll drop a link at the end of this blog so you can check it out later.
In the index.ts file, we just write a single line.
export { default } from './TextStory';
This is our final result of text story form:
The default color of text will be white (I set it using CSS, but you make a list of available colors and let the user choose the color if you want).
3.2. Image story
Okay, this is the main part of this blog and it will be a tougher one.
Because we have to do these things:
- Display image (in this project we will read it from the URL, but you can change it to upload from your machine)
- Add texts: We can add multi-text blocks and with each block, we can change the text in there, drag, rotate, resize it.
It's time for the fabric to come into play.
In story form, create a folder called Image. Then in that folder, create a file called ImageStory.tsx.
let's write some code in there
import React, { ChangeEvent, useState } from "react";
import { FabricJSCanvas, useFabricJSEditor } from "fabricjs-react";
import classes from "./imageStory.module.css";
const ImageStory = () => {
const { editor, onReady } = useFabricJSEditor()
return (
<div className={classes.root}>
<div className={classes.main}>
<FabricJSCanvas className={classes.canvas} onReady={onReady} />
</div>
</div>
);
};
export default ImageStory;
Now add a form to hold our image URL and a submit function for that form.
import React, { ChangeEvent, useState } from "react";
import { fabric } from "fabric";
import { FabricJSCanvas, useFabricJSEditor } from "fabricjs-react";
import classes from "./imageStory.module.css";
const ImageStory = () => {
const [image, setImage] = useState<string | null>(null);
const [isSubmitted, setIsSubmitted] = useState<boolean>(false);
const { editor, onReady } = useFabricJSEditor();
const submitImage = () => {
if (image && image.startsWith("http")) {
fabric.Image.fromURL(image, function (img) {
const canvasWidth = editor?.canvas.getWidth();
const canvasHeight = editor?.canvas.getHeight();
editor?.canvas.setWidth(500);
editor?.canvas.setHeight(500);
editor?.canvas.add(img);
const obj = editor?.canvas.getObjects();
obj?.forEach((o) => {
if (o.type === "image") {
o.scaleToHeight(canvasWidth || 100);
o.scaleToHeight(canvasHeight || 100);
}
});
editor?.canvas.centerObject(img);
setIsSubmitted(true);
});
}
};
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
setImage(value);
};
return (
<div className={classes.root}>
<div className={classes.main}>
{!isSubmitted && (
<div className={classes.imageForm}>
<input type="text" onChange={onChange} />
<button onClick={submitImage}>Submit</button>
</div>
)}
<FabricJSCanvas className={classes.canvas} onReady={onReady} />
</div>
</div>
);
};
export default ImageStory;
We have a state that stores our image URL
Because I want to show form only when we didn't submit the image, so I added isSubmitted state to deal with that. We only show image form if isSubbmitted = false.
Okay, let's take a look at the onSubmit function:
const submitImage = () => {
if (image && image.startsWith("http")) {
fabric.Image.fromURL(image, function (img) {
// Note that img now will be an fabric object
// get width and height of canvas container
const canvasWidth = editor?.canvas.getWidth();
const canvasHeight = editor?.canvas.getHeight();
// add image object
editor?.canvas.add(img);
// get all fabric objects in editor
const obj = editor?.canvas.getObjects();
// This will not optimal way, but currently
// we only have one image, so It should be fine
obj?.forEach((o) => {
if (o.type === "image") {
// resize image to fit with editor width and height
o.scaleToHeight(canvasWidth || 100);
o.scaleToHeight(canvasHeight || 100);
}
});
editor?.canvas.centerObject(img);
setIsSubmitted(true);
});
}
};
fabric supports read image from URL, it will return a fabric object then. in callback function, we add that object to current editor. One thing to keep in mind that the image now will keep its initial size so it might not fit with our editor area, we need to resize it to fit with editor area. My current solution is to get all objects in editor then resize it if it's image. Since we only have one image per story, this solution will work fine.
Now if you run your app and paste a valid image URL to form and hit submit, we will see it shows the image in the editor area. and you can interact with that image (drag, resize, rotate...). Good job. 😄
We finished our first goal, now let's move to the second one.
the fabric also supports text block, so adding text to our editor is easy.
Change our ImageStory component:
import React, { ChangeEvent, useState } from "react";
import { fabric } from "fabric";
import { FabricJSCanvas, useFabricJSEditor } from "fabricjs-react";
import classes from "./imageStory.module.css";
const ImageStory = () => {
const [image, setImage] = useState<string | null>(null);
const [isSubmitted, setIsSubmitted] = useState<boolean>(false);
const { editor, onReady } = useFabricJSEditor();
const onAddText = () => {
try {
editor?.canvas.add(
new fabric.Textbox("Type something...", {
fill: "red",
fontSize: 20,
fontFamily: "Arial",
fontWeight: "bold",
textAlign: "center",
name: "my-text",
})
);
editor?.canvas.renderAll();
} catch (error) {
console.log(error);
}
};
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
setImage(value);
};
const submitImage = () => {
if (image && image.startsWith("http")) {
fabric.Image.fromURL(image, function (img) {
const canvasWidth = editor?.canvas.getWidth();
const canvasHeight = editor?.canvas.getHeight();
editor?.canvas.add(img);
const obj = editor?.canvas.getObjects();
obj?.forEach((o) => {
if (o.type === "image") {
o.scaleToHeight(canvasWidth || 100);
o.scaleToHeight(canvasHeight || 100);
}
});
editor?.canvas.centerObject(img);
setIsSubmitted(true);
});
}
};
return (
<div className={classes.root}>
{isSubmitted && (
<aside className={classes.aside}>
<button onClick={onAddText}>Add Text</button>
<button onClick={saveToServer}>Save</button>
</aside>
)}
<div className={classes.main}>
{!isSubmitted && (
<div className={classes.imageForm}>
<input type="text" onChange={onChange} />
<button onClick={submitImage}>Submit</button>
</div>
)}
<FabricJSCanvas className={classes.canvas} onReady={onReady} />
</div>
</div>
);
};
export default ImageStory;
Let's take a look at onAddText function. We create a new fabric Textbox object by calling new fabric.Textbox().
editor?.canvas.add(
new fabric.Textbox("Type something...", {
fill: "red",
fontSize: 20,
fontFamily: "Arial",
fontWeight: "bold",
textAlign: "center",
name: "my-text",
})
);
editor?.canvas.renderAll();
Let me explain the params we passed: The first argument will be the initial text and the second one will be an object that contains configuration for text in that textbox. In the above code, I'll create a text that contains a red bold text which has font-size is 20 and font-family is Arial, the text will be aligned center in the textbox. After creating the textbox, we will add it to our editor using editor.canvas.add(..), and finally, we re-render the editor to get the latest state.
This is our final result:
Okay, up until now, we're done with adding the image and text. What's about deleting? With fabric, it's like a piece of cake, fabric has a removal method where we just need to pass objects we want to remove and fabric will handle it for us. But how do we get the object to pass to remove method?
Remember how we delete things, we will select it first, right? So fabric has a method called "getActiveObjects", by using that method, we can get all selected objects. Hah, problem solved, we just need to get all active objects, then loop through them and call remove method.
Like this:
const deleteSelected = () => {
editor?.canvas.getActiveObjects().forEach((object) => {
editor?.canvas.remove(object);
});
};
Okay, so we're done with all the basic features. Now let's move to the next step.
3.3. Save and show data
We can add, move things so far, but our app is not just interaction things, we need to store it in our database and show data from the database right? So how could we do that with fabricjs?
In this small project, I will use local storage as our database to make it easier. About the form of data, I think text is the best way. We just need to create an object then use JSON.stringify with that object.
With the text story feature, we don't have many things to do. The information we need to store is text content and background color.
const saveToServer = () => {
const data = {
background,
text,
};
localStorage.setItem("data", JSON.stringify(data));
};
Add this function to Text Story Form component and add a button which onClick event is saveToServer and we're done with it.
Now move to image story, again, thanks to fabric, we has a method called toJSON() which converts objects data in our editor to JSON, now we just need to call JSON.stringify with converted objects data and save it to local storage
const saveToServer = () => {
const objects = editor?.canvas.toJSON();
if (objects) {
localStorage.setItem("data", JSON.stringify(objects));
}
};
To show data, first, we get data from local storage and JSON.parse that data
const showResultFromServer = () => {
const json = localStorage.getItem("data");
if (json) {
const objects = JSON.parse(json);
// store it to component state.
}
};
With text story, after parsing data, we now have text content and background color. Using it to show data is easy, right? Our only concern is how to show image story because it was controlled by fabric. Luckily, fabric has a method called "loadFromJSON", we only need to pass JSON data we got from toJSON method and fabric will handle the rest for us.
For example, we can do this:
editor.canvas.loadFromJSON(
data,
() = {}
);
loadFromJSON has 2 params, the first is JSON data and the second is a callback function, the callback function will be called when JSON is parsed and corresponding objects (in this case, they're image objects and texts objects) are initialized. We don't need the callback function so let it be an empty function for now.
Okay, so we're all done with it.
The full source code can be found here:
https://github.com/trunghieu99tt/Facebook-story-mini
In this tutorial, I'm learning and writing this blog at the same time, so there might be better ways to use fabricjs or better ways of handling things I mentioned in this blog. :D If you have any suggestions, please feel free to drop a comment and I'll check it out. Thank you very much.
Top comments (1)
Is there any way to create component and import in fabric js canvas. Like a editable table ?