fabric实现裁剪、亮度调整、马赛克、增加图标、保存图片!

2,196 阅读10分钟

项目背景:

实现一款图像编辑器,功能包括裁剪、亮度调整、马赛克、扎点、方向选择(由于这部分和业务强相关不做介绍)、撤销重做、图片保存。

实现图像编辑器与应用项目的最大解耦,把编辑器相关的编辑作为一个单独npm包进行开发,项目使用是只需安装npm包传递参数即可使用。

操作流程:图片缩放 合适位置裁剪 亮度调整 马赛克 扎点 点移动 撤销 点移动 保存图片

技术选型:

fabric.jskonva.jspixi.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.6k11.3k43.5k
Fork数量3.5k9104.8k
官方文档纯英文档(难懂掘金基础教学(非官方 但nice英文文档(难懂中文文档(有示例英文文档(难懂Pixi.js中文网(ok
编辑能力编辑能力强,支持文本、图片、形状等多种图形元素的编辑,可以进行拖拽、缩放、旋转、变换等操作,还支持多种样式和效果。编辑能力强,支持多种图形元素的编辑,可以进行拖拽、缩放、旋转、变换等操作,但在样式和效果方面略显不足。编辑能力较好, 可以创建高性能的2D图形,具有丰富的渲染功能,可以进行高级的图形设计和编辑,但缺少拖拽、缩放等基础编辑功能。
性能以及适用场景性能一般,适用于开发小型应用。性能较好,可以处理大量图形元素,适合开发复杂的应用。。性能优异,适用于大规模的用户界面和游戏开发,例如创建复杂的用户界面、2D游戏等。
学习及开发成本常用功能封装齐全,可参考案例较多,上手和使用成本一般可直接参考的案例较少,暴露的api较少,学习成本较高; 对ts的支持比较高,架构设计更灵活,开发成本相对较低。需要开发者具备一定的图形设计能力,学习成本较高
社区和支持较大的社区支持和活跃的开发者社群中等的社区支持中等的社区支持
参考案例github案例:vue+frabic.jsPolotno 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_ratio
    • new_height = height
  • 情况 2:使用宽度计算高度
    • new_width = width
    • new_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中添加图表后也会有这个操作。

场景问题:

在首次添加图标时在画布上移动图标没有任何问题,当我移动了图标,又想恢复回原来的位置,这个时候我就会点击撤销。点击撤销后我又想手动移动图标的位置,发现图标移动不了了。

原因:
    1. JSON 序列化的局限性:Fabric.js 使用的序列化方法(如 toObjecttoJSON)是基于 JSON 格式的,而 JSON 本质上只支持数据结构,如对象、数组、字符串、数字和布尔值。函数、事件处理器这些非数据内容无法被序列化,因为它们无法转换为 JSON 格式。
    2. Fabric 对象序列化的设计:Fabric.js 的 toObject()toJSON() 方法仅序列化对象的属性(如位置、大小、颜色、旋转等),而不会序列化与对象绑定的事件。序列化后的 JSON 主要用于数据存储、传输和状态恢复,而事件是运行时逻辑的一部分,不包含在数据模型中。
解决方法:

在反序列化后手动重新绑定事件

8.保存图片

实现思路:

使用canvas.toDataURL将画布转化DataURL,再去请求这个url获取blob格式的数据,将blob格式的数据作为参数发送给后端接口,由后端保存编辑好的图片数据。

toDataURL可以设置图片格式和图片质量。

代码实现:

实现过程中参考了很多大佬的文章,但是由于写总结的时候已经找不到出处了,90度鞠躬。现在大部分只放了实现的demo,如果有更好的方式欢迎大家一起讨论~。