使用 ReactJS 和 FabricJS 构建 Facebook 故事创建器
那时候,我正在开发一个非常大的项目,一个社交媒体应用。我希望我的应用能有一个非常有趣的功能,是的,那就是“故事”功能,用户可以分享一些内容,这些内容会在24小时后自动隐藏。我决定开发一个更简单的版本,今天我想和大家分享一下我开发Facebook故事创建器的经验。
现场演示:https://trunghieu99tt.github.io/Facebook-story-mini/
1. 范围
首先,我们来定义一下范围。Facebook 移动端应用的故事功能非常庞大,其中包含许多小功能,但 Facebook 网站上的故事功能则不然。
在网站上,我们只有两个选项。1. 文字故事,2. 带文字的图片故事。在这篇博客中,我将使用 Facebook 网站上的故事功能,我认为它更简洁。
好的,让我们进一步看看我们该做什么
- 文字故事:中间一段文字,背景可变换
- 图片故事:每个故事一张图片,我们还可以添加文本块
看起来很简单,对吧?至少有了文字故事功能。好的,我们进入下一部分
2. 工具、库
我使用 ReactJS 开发了这个功能,对于文本故事来说已经足够了,但对于图像故事,我们需要找到一个库来帮助我们处理文本块的添加/删除、方向、大小调整等等。于是我想到了Fabric。Fabric在画布元素之上提供了交互式对象模型,这正是我们想要实现的。我觉得你最好先去 Fabric 官网了解一下,然后再继续阅读。
3. 开始编码
你可以使用任何你想要的样板代码,对我来说,我会坚持使用 Create React App。我假设你们已经具备 React 的基础知识,并且知道如何创建和运行 React App。另外需要注意的是,在这个项目中,我会使用 Typescript,但我认为大家可能不了解 Typescript,但这没什么大不了的,因为这只是一个小项目。
在这个项目中,我们需要添加另外 2 个包:fabric 和 fabricjs-react(实际上我们不需要这个包,但为了使事情更容易,使用它是可以的)。
运行此命令:
yarn add fabric fabricjs-react
#or
npm install fabric fabricjs-react
好的,现在我们可以开始了。
在进行下一步之前,让我们先定义一下文件夹结构。我们知道组件主要有两种类型:1. 故事表单,用于创建文本或图片故事;2. 查看器组件,用于在创建并保存文本/图片故事后显示来自服务器的数据。我将创建一个如下文件夹结构:
Constants 文件夹将保存我们在此应用程序中使用的所有常量值。
3.1. 文本故事
关于文本故事,它是更简单的,我们只需要一个 div 和位于该 div 中心的文本。我们还可以更改该 div 的背景。
在 StoryForm 中,创建一个名为 Text 的文件夹,在该文件夹中,创建 3 个文件:index.ts(我们的入口文件)、textStory.module.css 和 TextStory.tsx。
在 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;
以上是该组件的完整代码。我们有一个状态用于存储文本,还有一个状态用于存储背景颜色。关于 saveToServer 函数,您可以忽略它,我们将在本博客的稍后部分讨论它。对于背景颜色列表,在本项目中,我们将对其进行硬编码(但您可以将其更改为颜色选择器或任何您想要的更好的功能)。
在 Constants 文件夹中创建一个 index.ts 文件并将其放入其中:
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%)',
];
关于样式文件,它有点长,所以我就不在这里贴了。不过我会在这篇博文的末尾放一个链接,方便你以后查看。
在 index.ts 文件中,我们只需写一行。
export { default } from './TextStory';
这是我们最终的文本故事形式的结果:
文本的默认颜色为白色(我使用 CSS 设置它,但您可以列出可用的颜色并让用户根据需要选择颜色)。
3.2. 图像故事
好的,这是本博客的主要部分,而且会比较难。
因为我们必须做这些事情:
- 显示图像(在本项目中,我们将从 URL 读取图像,但您可以将其更改为从您的机器上传)
- 添加文本:我们可以添加多文本块,并且对于每个块,我们可以更改其中的文本,拖动、旋转、调整大小。
现在该是织物发挥作用的时候了。
以故事形式创建一个名为 Image 的文件夹。然后在该文件夹中创建一个名为 ImageStory.tsx 的文件。
让我们在其中写一些代码
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;
现在添加一个表单来保存我们的图像 URL 和该表单的提交功能。
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;
我们有一个存储图像 URL 的状态
因为我希望仅在未提交图片时显示表单,所以我添加了 isSubmitted 状态来处理这个问题。只有当 isSubbmitted = false 时,我们才会显示图片表单。
好的,我们来看看onSubmit函数:
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 支持从 URL 读取图片,然后返回一个 fabric 对象。在回调函数中,我们将该对象添加到当前编辑器。需要注意的是,图片现在会保持其初始大小,因此可能不适合我们的编辑器区域,我们需要调整其大小以适应编辑器区域。我目前的解决方案是获取编辑器中的所有对象,然后如果是图片则调整其大小。由于每个故事只有一张图片,因此此解决方案可以正常工作。
现在,如果你运行应用,将有效的图片 URL 粘贴到表单并点击提交,我们就会看到图片显示在编辑器区域。你可以与该图片进行交互(拖动、调整大小、旋转……)。干得好!😄
我们完成了第一个目标,现在让我们转向第二个目标。
该结构还支持文本块,因此向我们的编辑器添加文本很容易。
更改我们的 ImageStory 组件:
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;
我们来看一下 onAddText 函数。我们通过调用 new fabric.Textbox() 创建了一个新的 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();
让我解释一下我们传递的参数:第一个参数是初始文本,第二个参数是一个包含文本框文本配置的对象。在上面的代码中,我将创建一个包含红色粗体文本的文本框,其字体大小为 20,字体系列为 Arial,文本框将居中对齐。创建文本框后,我们将使用 editor.canvas.add(..) 将其添加到编辑器中,最后,我们重新渲染编辑器以获取最新状态。
这是我们的最终结果:
好了,到目前为止,我们已经完成了图片和文字的添加。那么删除呢?有了 Fabric,删除就变得轻而易举了。Fabric 有一个移除方法,我们只需要传入想要移除的对象,Fabric 就会帮我们处理。但是,我们如何获取要传递给移除方法的对象呢?
还记得我们删除东西时,会先选中它吗?Fabric 有一个名为“getActiveObjects”的方法,通过该方法,我们可以获取所有选中的对象。哈哈,问题解决了,我们只需要获取所有活动对象,然后循环遍历它们并调用 remove 方法即可。
像这样:
const deleteSelected = () => {
editor?.canvas.getActiveObjects().forEach((object) => {
editor?.canvas.remove(object);
});
};
好了,所有基本功能都讲完了。现在我们进入下一步。
3.3. 保存并显示数据
到目前为止,我们可以添加、移动内容,但我们的应用不仅仅是交互,我们还需要将数据存储到数据库中并显示其中的数据,对吗?那么,我们该如何使用 fabricjs 来实现呢?
在这个小项目中,我将使用本地存储作为数据库,以简化操作。关于数据形式,我认为文本是最好的。我们只需要创建一个对象,然后对该对象使用 JSON.stringify 即可。
有了文本故事功能,我们要做的事情就不多了,我们需要存储的信息就是文本内容和背景颜色。
const saveToServer = () => {
const data = {
background,
text,
};
localStorage.setItem("data", JSON.stringify(data));
};
将此功能添加到 Text Story Form 组件并添加一个按钮,其 onClick 事件为 saveToServer,我们就完成了。
现在转到图像故事,再次感谢 fabric,我们有一个名为 toJSON() 的方法,它将编辑器中的对象数据转换为 JSON,现在我们只需要使用转换后的对象数据调用 JSON.stringify 并将其保存到本地存储
const saveToServer = () => {
const objects = editor?.canvas.toJSON();
if (objects) {
localStorage.setItem("data", JSON.stringify(objects));
}
};
为了显示数据,首先,我们从本地存储中获取数据并使用 JSON.parse 对该数据进行解析
const showResultFromServer = () => {
const json = localStorage.getItem("data");
if (json) {
const objects = JSON.parse(json);
// store it to component state.
}
};
有了文本故事,解析数据后,我们现在有了文本内容和背景颜色。用它来显示数据很容易,对吧?我们唯一关心的是如何显示图像故事,因为它是由 fabric 控制的。幸运的是,fabric 有一个名为“loadFromJSON”的方法,我们只需要传递从 toJSON 方法获取的 JSON 数据,fabric 会为我们处理剩下的事情。
例如,我们可以这样做:
editor.canvas.loadFromJSON(
data,
() = {}
);
loadFromJSON 有两个参数,第一个是 JSON 数据,第二个是回调函数。回调函数会在 JSON 解析完成后调用,并初始化相应的对象(在本例中是图片对象和文本对象)。我们不需要回调函数,所以暂时将其设置为空函数。
好的,我们已经完成了。
完整的源代码可以在这里找到:
https://github.com/trunghieu99tt/Facebook-story-mini
在本教程中,我一边学习,一边撰写这篇博客,所以或许有更好的方法来使用 fabricjs,或者有更好的方法来处理我在这篇博客中提到的问题。:D 如果您有任何建议,请随时发表评论,我会查看。非常感谢。
文章来源:https://dev.to/trunghieu99tt/build-a-facebook-story-creator-using-reactjs-and-fabricjs-5gl2