前言
Fabric.js 作为一个功能强大、操作简单的 HTML5 画布库。尝试学习如何借助它更好地进行 canvas 开发;
以实现创客贴、易企秀、稿定设计这些设计平台的智能抠图功能为例,开发一个在线抠图的图片编辑工具。
在线示例 demo:fabric.js-在线抠图工具
项目地址:fabric-remove-bg | Github
开始搭建
页面结构
确定根页面 index.vue
的页面布局,包括侧边栏(Sidebar),头部(Header),以及主内容区;
其中分别左右两个面板(ImagePanel,FabricPanel)就是我们的页面主内容;
<div class="flex h-full">
<Sidebar />
<div class="flex flex-col flex-1">
<Header />
<div class="wrapper">
<div class="panel-container">
<BasePanel ref="formImageBasePanel">
<!-- 左侧图片 -->
<ImagePanel></ImagePanel>
</BasePanel>
</div>
<div class="panel-container left-border">
<BasePanel ref="fabricBasePanel">
<!-- 右侧画布 -->
<FabricPanel></FabricPanel>
</BasePanel>
</div>
</div>
</div>
</div>
基础面板
BasePanel 是一个基础面板,提供了对插入内容进行设置大小、拖拽、缩放的能力;我们将其封装为 BasePanel.vue
组件:
<!-- BasePanel.vue -->
<div ref="basePanelRef" class="base-panel">
<slot></slot>
</div>
接下来为该组件实现基础能力:
拖拽位置
编写一个 useDraggable
组合函数,内部使用 @use-gesture
实现对传入 DOM 拖拽修改 CSS 位置;
<!-- BasePanel.vue -->
<script setup>
import { useDraggable } from "../../composables/useDraggable.js";
// 拖动位置
const { x, y, draggable, setDraggable, setPosition } = useDraggable(
basePanelRef,
{
enabled: false,
onDrag: (position) => emit("onDrag", position),
}
);
</script>
<template>
<div
ref="basePanelRef"
:style="{
left: `${x.toFixed()}px`,
top: `${y.toFixed()}px`,
}"
:class="{ draggable }"
class="base-panel"
>
<slot></slot>
</div>
</template>
设置大小
BasePanel 作为容器,支持外部设置容器大小,这也是后续缩放的基础能力
<!-- BasePanel.vue -->
<script setup>
// 操作面板大小
const width = ref(0);
const height = ref(0);
const getWidthHeight = () => ({ width: width.value, height: height.value });
const setWidthHeight = (options) => {
width.value = options.width;
height.value = options.height;
};
</script>
<template>
<div
ref="basePanelRef"
:style="{
width: `${width}px`,
height: `${height}px`,
left: `${x.toFixed()}px`,
top: `${y.toFixed()}px`,
}"
:class="{ draggable }"
class="base-panel"
>
<slot></slot>
</div>
</template>
面板缩放
编写一个 useWheel
组合函数,内部使用 @use-gesture
实现对 DOM 监听滚轮操作
<!-- BasePanel.vue -->
<script setup>
// 滚轮缩放
const scaleStep = 0.2; // 缩放比增减步长
const onWheel = (dy, isTrusted = true) => {
const diff = dy > 0 ? -scaleStep : scaleStep;
const oldWidthHeight = { width: width.value, height: height.value };
// 放大/缩小宽高
setWidthHeight({
width: width.value * (1 + diff),
height: height.value * (1 + diff),
});
// 保证基于中心缩放
const leftOffset = (width.value - oldWidthHeight.width) >> 1;
const topOffset = (height.value - oldWidthHeight.height) >> 1;
setPosition({ x: x.value - leftOffset, y: y.value - topOffset });
isTrusted && emit("onWheel", dy);
};
const triggerWheel = (dy) => onWheel(dy, false); // 支持手动触发缩放
useWheel(basePanelRef, { onWheel });
</script>
BasePanel 外部方法
最终,BasePanel 通过 defineExpose
暴露给外部对面板容器的操作方法:
defineExpose({
setDraggable, // 开启/禁用拖拽
setXY: setPosition, // 设置位置坐标
getWidthHeight, // 获取宽高
setWidthHeight, // 设置宽高
triggerWheel, // 手动触发缩放
});
画布面板
主内容区由左侧原图片面板和右侧fabric画布面板组成,左侧图片面板独立为 ImagePanel
,将原图片回显即可:
<!-- ImagePanel.vue -->
<script setup>
const props = defineProps({
fromImageURL: { type: String, required: true }, // 原图片地址
});
</script>
<template>
<img class="w-hull h-full" :src="fromImageURL" draggable="false" />
</template>
右侧画布面板独立为 FabricPanel
,初始化一个fabric画布对象,并对外提供fabric画布基础操作方法
初始化画布
通过 initFabric
方法,当 FabricPanel
组件渲染时,初始化执行以下操作:
- 创建fabric实例
- 加载底图到画布
- 加载抠图到画布
创建fabric实例
编写一个 useFabric
,它以传入的canvas元素创建强大的fabric画布对象并返回;
<script setup>
const fabricCanvasRef = ref(null);
let fabricInstance; // 画布对象
const initFabric = async () => {
// 原图片 移除背景后图片 额外fabric画布参数
const { fromImage, removeBgImage, fabricOptions } = props;
// 创建fabric实例
const defaultOptions = {
selection: false, // 画布不可框选
hoverCursor: "default", // 画布hover时鼠标cursor
freeDrawingCursor: "default", // 画布画笔绘画时鼠标cursor
isDrawingMode: false, // 初始是否为可绘画模式
};
fabricInstance = useFabric(fabricCanvasRef.value, { ...defaultOptions, ...fabricOptions });
// ......
}
</script>
<template>
<canvas ref="fabricCanvasRef"></canvas>
</template>
加载底图到画布
后续我们是可以通过【修补】画笔在画布上绘画,将图片上不应该被移除的部分绘制出来,其实是因为我们画布上默认放入了【被整个擦除】的底图内容,当使用【修补】画笔在画布涂抹时,只不过将被擦除的底图进行【反向擦除】,是底部部分内容显现出来,即实现【修补】效果
<script setup>
const initFabric = async () => {
// ......
fabricInstance = useFabric(fabricCanvasRef.value, { ...... });
// 加载底图
fabric.Image.fromURL(
fromImage,
function (img) {
img.scaleToWidth(fabricInstance.width);
img.scaleToHeight(fabricInstance.height);
img.set("selectable", false);
// 底图默认被擦除
let path = new fabric.Path(
`M 0 0 L ${img.width} 0 L ${img.width} ${img.height} L 0 ${img.height} z`,
);
path.set("globalCompositeOperation", "destination-out");
fabric.EraserBrush.prototype._addPathToObjectEraser(img, path);
fabricInstance.add(img);
},
{ crossOrigin: "Anonymous" },
);
// ......
}
</script>
加载抠图到画布
即将基于原图片的被移除背景图片绘制到画布顶层
<script setup>
const initFabric = async () => {
// ......
// 加载抠图
fabric.Image.fromURL(
removeBgImage,
function (img) {
img.scaleToWidth(fabricInstance.width);
img.scaleToHeight(fabricInstance.height);
img.set("selectable", false);
fabricInstance.add(img);
emit("initialized", fabricInstance); // 初始化完成
},
{ crossOrigin: "Anonymous" },
);
}
</script>
画笔绘制
有了fabric画布后,支持对已去除背景图片进行过编辑。
【擦除】画笔下,通过在画布上涂抹实现对画布内容擦除;
【修补】画笔下,通过在画布上涂抹实现对画布内容反向擦除,即将去除部分补回来;
<script setup>
// ......
// 是否可绘画模式
const getIsDrawingMode = () => fabricInstance.isDrawingMode;
// 开启/关闭画布可绘画
const setIsDrawingMode = (isDrawingMode) => {
fabricInstance.set("isDrawingMode", isDrawingMode);
};
// 设置擦除/修补画笔
const setDrawingBrush = ({ inverted, width }) => {
const eraserBrush = new fabric.EraserBrush(fabricInstance);
eraserBrush.inverted = inverted; // 是否反向擦除
eraserBrush.width = width;
fabricInstance.freeDrawingBrush = eraserBrush;
};
// 设置画笔粗细
const setDrawingBrushWidth = (width) => {
fabricInstance.freeDrawingBrush.width = width;
};
</script>
设置背景
fabric提供了对画布设置背景图/背景色的方法,基于它们编写操作画布方法:
背景色
const setBackgroundColor = (color) => {
fabricInstance.setBackgroundImage(null);
// 设置背景色
fabricInstance.setBackgroundColor(color, fabricInstance.renderAll.bind(fabricInstance));
};
背景图
// 设置画布背景图
const setBackgroundImage = (url) => {
fabric.Image.fromURL(
url,
function (img) {
// 背景图类似 object-fit: cover 的规则填满画布
let scaleX = fabricInstance.width / img.width;
let scaleY = fabricInstance.height / img.height;
const scale = Math.ceil(Math.max(scaleX, scaleY) * 100) / 100;
img.set({
scaleX: scale,
scaleY: scale,
left: fabricInstance.width >> 1,
top: fabricInstance.height >> 1,
originX: "center",
originY: "center",
});
img.set("erasable", false); // 背景图不可擦除
fabricInstance.setBackgroundColor("");
// 设置背景图
fabricInstance.setBackgroundImage(img);
fabricInstance.renderAll();
},
{ crossOrigin: "Anonymous" },
);
};
历史记录
为编辑工具提供操作回退、重做和重置能力,这里基于我自己开发的 fabricjs-history
专为fabric画布对象实现历史记录功能;
为画布对象创建历史记录
在初始化 initFabric
方法的【加载抠图】步骤的初始化完成回调中,为画布创建历史记录:
// initFabric
// 加载抠图
fabric.Image.fromURL(
removeBgImage,
function (img) {
// .....
fabricInstance.add(img);
// 基于当前画布状态作为历史记录起点,创建历史记录
createHistory({ canvas: fabricInstance, historyEvent: ["erasing:end"] });
emit("initialized", fabricInstance);
},
{ crossOrigin: "Anonymous" },
);
historyEvent: ["erasing:end"]
即每次擦除/反向擦除涂抹完成,记录快照到历史记录;
除此之外,我们手动在设置背景色/背景图时,手动调用方法 fabricInstance.record
记录到历史记录,实现设置背景动作时可回退的;
我们只需在index.vue中使用fabricjs-history
提供的方法 fabricInstance.undo
、fabricInstance.redo
、fabricInstance.reset
对画布重做/回退/重置
导出图片
fabric画布对象上有 toDataURL
方法,将其转为base64格式数据的图片,基于此实现导出画布为图片方法:
// 导出画布为图片
import { saveAs } from "file-saver";
const saveAsImage = () => {
// 由于画布此时可能是缩小/放大状态,我们先将其置为原图片大小,导出后再回到缩放大小
const currentZoom = fabricInstance.getZoom();
const currentWidth = fabricInstance.width;
const currentHeight = fabricInstance.height;
fabricInstance.setDimensions({
width: props.fromImageSize.width,
height: props.fromImageSize.height,
});
fabricInstance.setZoom(1);
const dataURL = fabricInstance.toDataURL();
saveAs(dataURL, "image.png");
fabricInstance.setDimensions({
width: currentWidth,
height: currentHeight,
});
fabricInstance.setZoom(currentZoom);
};
暴露画布外部方法
至此,我们的 FabricPanel 已经能暴露一下基础操作方法给 index.vue
根页面
defineExpose({
getIsDrawingMode, // 是否为绘画模式
setIsDrawingMode, // 开启/关闭绘画模式
setDrawingBrush, // 设置绘制画笔[擦除/反向擦除]
setDrawingBrushWidth, // 设置画笔粗细
setWidthHeight, // 设置fabric画布大小
setBackgroundColor, // 设置画布颜色
setBackgroundImage, // 设置画布背景图
saveAsImage, // 导出为图片
});
接下来,我们在 Sidebar
侧边栏 和 Header
头部中,通过事件传递调用到以上基础方法,就能实现对画布(FabricPanel)执行各种操作;具体调用细节见 index.vue
抠图接口
FabricPanel 已具备基础操作画布能力,接下来就是进入根页面调用【去除图片背景】接口,得到去除原图片背景的图片后,传递给 FabricPanel 显示即可;
编写 removeBgService
,包含去除图片背景HTTP请求方法:
import { request } from "@/utils/index.js";
const VITE_APP_REMOVE_BG_KEY = import.meta.env.VITE_APP_REMOVE_BG_KEY;
const VITE_APP_REMOVE_BG_KEY2 = import.meta.env.VITE_APP_REMOVE_BG_KEY2;
export function fetchRemoveBg(data) {
return request.post("https://api.remove.bg/v1.0/removebg", data, {
responseType: "blob",
headers: {
"Content-Type": "multipart/form-data",
"x-api-key": VITE_APP_REMOVE_BG_KEY,
},
});
}
export function fetchRemoveBg2(data) {
return request.post("https://clipdrop-api.co/remove-background/v1", data, {
responseType: "blob",
headers: {
"Content-Type": "multipart/form-data",
"x-api-key": VITE_APP_REMOVE_BG_KEY2,
},
});
}
fetchRemoveBg
方法使用的是remove.bg
去除背景;fetchRemoveBg2
方法使用的是clipdrop API去除背景;他们都将在 useRemoveBg
中被调用;
API Key各自在 .env
环境变量中配置,更多 抠图接口 迁移细节见 fabric-remove-bg | Remove.bg API
更新:由于自己的remove.bg API接口次数耗尽,最新代码改用效果稍差的Koutu API;你也可以使用自己的remove.bg API key调用抠图;
图片裁剪
图片裁剪的实现,之前我是通过 vue-drag-resize 实现一个裁剪框到裁剪面板,支持用户拖动裁剪框大小位置自定义图片裁剪区域;
另外,fabric导出图片 toDataURL
方法支持裁剪部分导出:
const dataURL = ctx.toDataURL({
width: width,
height: height,
left: left, // 裁剪起始x坐标
top: top, // 裁剪起始y坐标
});