项目背景:
实现一款图像编辑器,功能包括裁剪、亮度调整、马赛克、扎点、方向选择(由于这部分和业务强相关不做介绍)、撤销重做、图片保存。
实现图像编辑器与应用项目的最大解耦,把编辑器相关的编辑作为一个单独npm包进行开发,项目使用是只需安装npm包传递参数即可使用。
操作流程:图片缩放 合适位置裁剪 亮度调整 马赛克 扎点 点移动 撤销 点移动 保存图片
技术选型:
| fabric.js | konva.js | pixi.js | |
|---|---|---|---|
| 简介 | Fabric.js是一个开源的JavaScript库,用于创建交互式的Canvas应用程序。 | Konva.js是一个HTML5 Canvas库,用于创建交互式的Canvas应用程序。 | Pixi.js是一个快速、轻量级的2D渲染引擎,可用于创建游戏、动画和交互式应用程序。 |
| github地址 | github.com/fabricjs/fa… | github.com/konvajs/kon… | github.com/pixijs/pixi… |
| Star数量**(截止2024.9.3** | 28.6k | 11.3k | 43.5k |
| Fork数量 | 3.5k | 910 | 4.8k |
| 官方文档 | 纯英文档(难懂掘金基础教学(非官方 但nice | 英文文档(难懂中文文档(有示例 | 英文文档(难懂Pixi.js中文网(ok |
| 编辑能力 | 编辑能力强,支持文本、图片、形状等多种图形元素的编辑,可以进行拖拽、缩放、旋转、变换等操作,还支持多种样式和效果。 | 编辑能力强,支持多种图形元素的编辑,可以进行拖拽、缩放、旋转、变换等操作,但在样式和效果方面略显不足。 | 编辑能力较好, 可以创建高性能的2D图形,具有丰富的渲染功能,可以进行高级的图形设计和编辑,但缺少拖拽、缩放等基础编辑功能。 |
| 性能以及适用场景 | 性能一般,适用于开发小型应用。 | 性能较好,可以处理大量图形元素,适合开发复杂的应用。。 | 性能优异,适用于大规模的用户界面和游戏开发,例如创建复杂的用户界面、2D游戏等。 |
| 学习及开发成本 | 常用功能封装齐全,可参考案例较多,上手和使用成本一般 | 可直接参考的案例较少,暴露的api较少,学习成本较高; 对ts的支持比较高,架构设计更灵活,开发成本相对较低。 | 需要开发者具备一定的图形设计能力,学习成本较高。 |
| 社区和支持 | 较大的社区支持和活跃的开发者社群 | 中等的社区支持 | 中等的社区支持 |
| 参考案例 | github案例:vue+frabic.js | Polotno Studio - Online Free Design Editor | 暂无 |
最终选择:fabric.js
选择原因:较高的start数量+性能及使用场景与需求匹配+更快的上手成本+较多社区和支持+优秀的参考案例
项目准备
工程化项目:react+webpack
项目架构设计
代码结构
具体功能介绍
1.状态管理
由于不同的工具之间有一些共同使用的数据,这些数据集中使用 useReducer 和 Context API的结合,可以实现类似 Redux 的全局状态管理
demo示例:如何创建全局状态管理与如何 引用
(1)创建
//全局状态管理
import React, { createContext, useReducer } from "react";
//初始化数据
const initialState = {
canvas: null,
};
//定义reducer
const reducer = (state, action) => {
switch (action.type) {
case "SET_CANVAS": // 设置canvas实例
return {
...state,
canvas: action.payload,
};
}
};
//创建全局上下文
export const GlobalContext = createContext();
//导出全局状态管理器
export const GlobalProvider = ({child}) => {
const [state, dispatch] = useReducer(reducer,initialState)
return(
<GlobalContext.Provider value={{ state, dispatch }}>
{children}
</GlobalContext.Provider>
);
}
(2)引入
import React from "react";
import App from "./App";
//导入全局context
import { GlobalProvider } from "./GlobalContext";
export const ImageEditor = ({
imageURL,
}) => {
return (
<GlobalProvider>
<App
imageURL={imageURL}
/>
</GlobalProvider>
);
};
export default ImageEditor;
(3)其他组件中使用(示例demo)
import React, {
useEffect,
useState,
forwardRef,
useContext,
} from "react";
import { fabric } from "fabric";
//导入GlobalContext
import { GlobalContext } from "../GlobalContext";
const FabricCanvas = ({ imageURL, containerWidth, containerHeight }) => {
const localCanvasRef = useRef(null);
//解构数据
const { state, dispatch } = useContext(GlobalContext);
// 初始化 Fabric 画布
useEffect(() => {
const canvas = new fabric.Canvas(localCanvasRef.current);
//为canvas赋值
dispatch({ type: "SET_CANVAS", payload: canvas });
}, []);
return (
<canvas
ref={localCanvasRef}
width={canvasDimensions.width}
height={canvasDimensions.height}
/>
);
};
export default FabricCanvas;
2.画布的的初始化(重点)
场景问题1:
拿到一张图片url后获取图片宽、高。已知图片宽高,和要求比例,如何求该比例下的图片大小,比如说图片大小为1920*1000,在16:9的比例下,该图片的最大正数宽高为多少
实现思路:
1. 计算图片的宽高比
- 宽高比(图像):
aspect_ratio_img = width / height - 例如,对于 1920x1000 的图片,
aspect_ratio_img = 1920 / 1000 = 1.92
2. 计算目标比例
- 宽高比(目标):
target_aspect_ratio = 16 / 9 ≈ 1.78
3. 比较宽高比并调整尺寸
我们有两种情况:
- 情况 1:如果
aspect_ratio_img > target_aspect_ratio,说明图片比目标比例更宽,此时我们需要以高度为基准计算新的宽度。 - 情况 2:如果
aspect_ratio_img < target_aspect_ratio,说明图片比目标比例更高,此时我们需要以宽度为基准计算新的高度。
4. 计算新尺寸
- 情况 1:使用高度计算宽度
-
new_width = height * target_aspect_rationew_height = height
- 情况 2:使用宽度计算高度
-
new_width = widthnew_height = width / target_aspect_ratio
demo示例:
结论:
由此可以得出该图片在固定比例小的最大宽高,因此可以以该图片最大的宽高为基准生成一张画布,这样图片导出时,可以保证最大清晰度的还原比例
1920*1000的图片在3:2的比例下编辑的效果
1920*1000的图片在16:9的比例下编辑的效果
场景问题2(小tips):
考虑到编辑器的使用场景,我们不能把编辑器设置的和画布大小一样,所有就有了这样一个问题,如何把1777*1000的画布放到对应比例(16:9)756:432的容器中。
实现思路:
在canvas的的style样式中增加transform属性去scale画布大小,但不改变画布真实大小
<div
className="image-edit-container"
style={{
width: containerWidth,//容器宽度
height: containerHeight,//容器高度
position: "relative",
overflow: "hidden",
}}
>
<canvas
ref={localCanvasRef}
width={canvasDimensions.width} //画布宽度
height={canvasDimensions.height} //画布高度
style={{
position: "absolute",
top: "0",
left: "0",
transform: `scale(${containerWidth / canvasDimensions.width}, ${containerHeight / canvasDimensions.height})`,
transformOrigin: "top left",
}}
/>
</div>
3.图像初始化(裁剪、编辑、浏览)
三种模式:因业务需求图像编辑器非纯编辑器,有裁剪、编辑、浏览(编辑和浏览状态下裁剪按钮会被禁止)三种模式。
裁剪:编辑器会对原始图像进行处理,当直接编辑行车记录仪图像时,会进入裁剪模式,首先会进行图片上时间戳的裁剪,裁剪到上80px(用户无感),用户在裁剪区域拖动、缩放图片确定裁剪范围。
编辑:进行除裁剪外的其他操作
浏览:只可浏览
关键点:
(1)使用fabric.Image.fromURL处理图像url 创建临时画布裁剪行车记录仪时间戳
(2)创建裁剪框、阴影遮罩、图片的缩放、限定图片的移动和缩放不能超过裁剪框的范围
//创建裁剪框
const clipRect = new fabric.Rect({
left: (canvasWidth - canvasWidth * 0.95) / 2,
top: (canvasHeight - canvasHeight * 0.95) / 2,
width: canvasWidth * 0.95,
height: canvasHeight * 0.95,
fill: "transparent",
stroke: "white",
strokeWidth: 2,
strokeDashArray: [5, 5],
selectable: false,
evented: false,
});
//阴影遮罩(这个用了比较蠢的方法,给裁剪框的外面四边创建4个阴影矩形框)
const topRect = new fabric.Rect({
left: 0,
top: 0,
width: canvasWidth,
height: clipRect.top,
fill: "rgba(0,0,0,0.5)",
selectable: false,
evented: false,
});
//限定图片的移动和缩放不能超过裁剪框的范围
const adjustImagePosition = () => {
const cropRect = clipRectRef.current;
const img = imgRef.current;
// 获取裁剪框的边界
const cropLeft = cropRect.left;
const cropTop = cropRect.top;
const cropRight = cropRect.left + cropRect.width;
const cropBottom = cropRect.top + cropRect.height;
// 获取图片的边界
const imgLeft = img.left;
const imgTop = img.top;
const imgRight = img.left + img.width * img.scaleX;
const imgBottom = img.top + img.height * img.scaleY;
// 调整图片位置,确保不会超出裁剪框
if (imgLeft > cropLeft) {
img.left = cropLeft;
}
if (imgTop > cropTop) {
img.top = cropTop;
}
if (imgRight < cropRight) {
img.left = cropRight - img.width * img.scaleX;
}
if (imgBottom < cropBottom) {
img.top = cropBottom - img.height * img.scaleY;
}
img.setCoords(); // Update object coordinates
};
4.亮度调整
fabric api:
if (canvas) {
const objects = canvas.getObjects();
//获取当前canvas上编辑的图片信息
editImg = objects.find((obj) => {
return obj.type === "image"
});
}
//处理图片亮度变化brightness的值为[-1,1]
const handlebrightnesschange = (newValue) => {
setInputValue(newValue);
if (!canvas) return;
if(editImg){
editImg.filters[0] = new fabric.Image.filters.Brightness({
brightness: newValue,
});
editImg.applyFilters();
}
canvas.renderAll();
};
5.马赛克(使用的是模糊效果BLUR)
实现思路:记录鼠标的开始位置和鼠标移动结束时的位置,生成矩形,将矩形位置的图片转化为url,为矩形位置的图片做亮度调整
6.撤销重做
实现思路:fabric是支持将画布序列化和反序列化的,可以将画布序列化为json串,api为canvas.toJSON,如果想把json再绘制到canvas上可以使用canvas.loadFromJSON
首先初始化一个数组用来存储redo、undo的数据(即在画布上的每一步操作),再初始化一个当前指向当前数组的指针,当canvas每次发生变化时向数组中push一个记录。
例子:当我们初始化好画布后我们现在堆栈中保存一个初始化好的canvas,此时堆栈长度为1,指针指向0的位置,我们向画布上绘制一个马赛克,这个时候再向堆栈中push一个序列化的canvas,指针指向1的位置,如果这个时候点击撤销,指针指向0的位置,点击重做指针指向1的位置。要注意一下撤销和重做的边界。撤销当指针指向0的位置的位置时不能再撤销了,重做当指针指向堆栈的最大长度时就不能继续点击重做了。
7.添加图标
在前面的撤销重做中提到了每一步我们都会讲canvas进行序列化放进堆栈中,同样的,当我们向canvas中添加图表后也会有这个操作。
场景问题:
在首次添加图标时在画布上移动图标没有任何问题,当我移动了图标,又想恢复回原来的位置,这个时候我就会点击撤销。点击撤销后我又想手动移动图标的位置,发现图标移动不了了。
原因:
-
- JSON 序列化的局限性:Fabric.js 使用的序列化方法(如
toObject或toJSON)是基于 JSON 格式的,而 JSON 本质上只支持数据结构,如对象、数组、字符串、数字和布尔值。函数、事件处理器这些非数据内容无法被序列化,因为它们无法转换为 JSON 格式。 - Fabric 对象序列化的设计:Fabric.js 的
toObject()或toJSON()方法仅序列化对象的属性(如位置、大小、颜色、旋转等),而不会序列化与对象绑定的事件。序列化后的 JSON 主要用于数据存储、传输和状态恢复,而事件是运行时逻辑的一部分,不包含在数据模型中。
- JSON 序列化的局限性:Fabric.js 使用的序列化方法(如
解决方法:
在反序列化后手动重新绑定事件
8.保存图片
实现思路:
使用canvas.toDataURL将画布转化DataURL,再去请求这个url获取blob格式的数据,将blob格式的数据作为参数发送给后端接口,由后端保存编辑好的图片数据。
toDataURL可以设置图片格式和图片质量。
代码实现:
实现过程中参考了很多大佬的文章,但是由于写总结的时候已经找不到出处了,90度鞠躬。现在大部分只放了实现的demo,如果有更好的方式欢迎大家一起讨论~。