画布:
- 在使用 fabric 的时候都需要创建一个canvas 来做画布并且创建一个ref 来实现引用
const canvasRef = useRef<HTMLCanvasElement|null>(null); return ( <div> <canvas ref={canvasRef}/> </div> )
- 并通过 ref 来创建一个 fabric.Canvas
const canvas = useMemo(() => { return new fabric.Canvas( canvasRef.current, { backgroundColor: '#e5e5e5', selection: false, // 画布不显示选中 width: canvasWidth, height: canvasHeight, fireRightClick: true, //右键点击事件生效 stopContextMenu: true, //右键点击禁用默认自带的目录 fireMiddleClick: true, //中间建点击事件生效 skipTargetFind: true, // 画板元素不能被选中, 一旦填了true,canvas on mouse:down 的参数里的target 将为null } ) }, [canvasRef.current])
- 使用useMemo 是为了防止组建重复创建,如果不用useMemo 除非每次渲染的时候都移除整个组建重新渲染,不然会生成多个画布,然后画布会堆叠导致后续的事件失效
- 如果画布的大小不确定,可以在render 完成之后修改canvas 的大小
useEffect(() => { if (!canvas || imageInfo.width === 0) { return; } canvas?.setWidth(imageInfo.width) canvas?.setHeight(imageInfo.height) }, [imageInfo])
- canvas 可以做事件关联常用的有 mouse:move, mouse:up, mouse:down, mouse:out, mouse:wheel
useEffect(() => { if (!canvas) { return; } const handleWheel = (handle: fabric.IEvent<WheelEvent>) => { const { e } = handle const isDown = e.deltaY > 0 e.preventDefault(); updateImageScale(isDown) } canvas.on('mouse:wheel', handleWheel) return () => { // @ts-ignore canvas.off('mouse:wheel', handleWheel) } }, [canvas])
- mouse:down可以获取当前点击的对象信息,target 里含有所有被点击的画布上的东西在创建时候传入的参数
useEffect(() => { const mouseDown = (e) => { const target = e.target || {} console.info(e.target) } canvas.on('mouse:down', mouseDown) return () => { canvas.off("mouse:down", mouseDown) } }, [imageInfo])
- 其他的mouse 相关的行为都可以获取对应的位置信息等
useEffect(() => { const handleMouseDown = (handle: fabric.IEvent<MouseEvent>) => { const { e } = handle const point = { x: e.pageX, y: e.pageY, } } canvas.on('mouse:down', handleMouseDown) return () => { canvas.off('mouse:down', handleMouseDown) } }, [canvas])
- 画布可以进行整体缩放,使用 setZoom 不确定缩放了多少可以用 getZoom获取相应的缩放比,缩放比默认是1,例如上下两个合起来就是一个通过滚轮来缩放画布的行为
const updateImageScale = (isDown) => { let off = 0.1; if (isDown) { off = -0.1; } let zoom: number = canvas.getZoom() + off; if (zoom <= 0.1) { zoom = 0.1; } if (canvasWheeled.current === 0) { canvasWheeled.current = canvas.getZoom(); } canvas.setZoom(zoom) }
- 画布整体偏移,画布可以在加了拖拽等事件之后对画布做偏移,需要使用absolutePan来做偏移
const handleMouseMove = (handle: fabric.IEvent<MouseEvent>) => { const { e } = handle const offset = { x: -1 * (e.pageX - point.x) + imageOffset.current.x, y: -1 * (e.pageY - point.y) + imageOffset.current.y, } canvas.absolutePan(offset) }
- 在对画布里的内容进行多次渲染的时候画布上回出现多个框,出现bug,所以在每次重新渲染的时候需要使用 remove 移除对应的图片,文本等。
useEffect(() => { /** 正常行为 ***/ return () => { imageInfos.map(img => { canvas.remove(img); //移除画布中的图片 }); } }, [imageInfo])
画图:
- 画图需要用到 fabric.Image 模块正常使用 fromUrl 来做创建image 对象,第二个参数是一个回调函数,函数的参数就是生成的img 对象,可以在回调函数内将图片放到canvas中
fabric.Image.fromURL(url, img => { canvas.add(img) // 这个img 就是上文中提到的 remove 用到的img,需要保存在某个地方然后做remove }, { selectable: false, // 是否可操作 hoverCursor: 'default', // 鼠标移动到图上时手势 top: 0, // 图片相对画布偏移量 left: 0, // 图片相对画布偏移量 })
- 图片可以做缩放,在add 到canvas 之前,可以使用 scale 对图片进行缩放
fabric.Image.fromURL(url, img => { img.scale(1.5) // 图片放大0.5倍 canvas.add(img) }, { selectable: false, // 是否可操作 hoverCursor: 'default', // 鼠标移动到图上时手势 top: 0, // 图片相对画布偏移量 left: 0, // 图片相对画布偏移量 })
- 在做多图片处理的时候有时候我们需要指定某个图放在某个图的上面或者下面,需进行排序,这就需要用到 moveTo(index: number),这个函数会指定图层是在第几层也就能实现排序了,如果要指定这个图肯定是在最上层可以使用 bringToFront(),如果是指定在最下层,可以使用sendToBack(),要注意的是这些函数需要在canvas add 完成之后在调用,否则是无效的
fabric.Image.fromURL( url, (img) => { img.scale(scale) canvas?.add(img) img.sendToBack() imageInfos.push(img) }, { left: images.region[0] * scale, top: images.region[1] * scale, selectable: false, hoverCursor: 'default', } )
- 只显示图片部分位置, cropX, cropY 可以指定要显示的图片的初始位置,width,height可以指定图片要显示的区域范围,注意,要显示的是图片缩放前的区域大小,也就所有的行为都是基于原始图片的宽高和位置并不是 使用了scale 缩放之后的
fabric.Image.fromURL( props.url, (img) => { img.cropX = region[0] img.cropY = region[1] img.width = region[4] - region[0] img.height = region[5] - region[1] img.scale(scale) canvas?.add(img) img.sendToBack() imageInfos.push(img) }, { left: region[0] * scale, // 这个是基于画布的偏移,所以需要考虑缩放比例 top: region[1] * scale, // 这个是基于画布的偏移,所以需要考虑缩放比例 selectable: false, hoverCursor: 'default', } )
- 如果插入的图片需要旋转,缩放,裁剪,并且要指定left 和 top ,切记要在img 里面定位left 和top,不然在使用的时候旋转中心还是以最初的图片大小的圆心做旋转,到时候你可能就看不见图片了
- 如果是要裁剪一个需要旋转的图,不用担心cropx 和 cropy 的问题,这两个位置也会跟着图片一起旋转,但如果你要裁剪的位置已经拿到了旋转之后的位置,那你需要映射回到之前的位置
//以一个矩形为例 const rotateImageRect = (regionInfo) => { const imageInfo = props.imageInfo; if (props.orientation == 90) { return { x: imageInfo.height - (regionInfo.y + regionInfo.h), y: regionInfo.x, w: regionInfo.h, h: regionInfo.w, } } else if (props.orientation == 180) { return { x: imageInfo.width - (regionInfo.x + regionInfo.w), y: imageInfo.height - (regionInfo.y + regionInfo.h), w: regionInfo.w, h: regionInfo.h } } else if (props.orientation == 270) { return { x: regionInfo.y, y: imageInfo.width -( regionInfo.x + regionInfo.w), w: regionInfo.h, h: regionInfo.w, } } else { return regionInfo; } }
- 在里面设置left 和top 之后旋转会以图片的左上角为中心做旋转,所以对位置做偏移的时候需要考虑图片的宽高信息
fabric.Image.fromURL( url, (img) => { const angle = ((360 - props.orientation) || 0) % 360 img.cropX = newRegion.x img.cropY = newRegion.y img.width = newRegion.w img.height = newRegion.h let left = images.region[0] let top = images.region[1] if (angle == 90) { left = left + newRegion.h; } else if (angle == 270) { top += newRegion.w } else if (angle == 180) { left = left + newRegion.w; top += newRegion.h } img.rotate(angle) .set("left", left * scale) .set("top", top * scale) img.scale(scale) canvas?.add(img) img.sendToBack() }, { selectable: false, hoverCursor: 'default', } )
- 渲染图片是异步渲染的,如果在使用图片翻页的情况下快速翻页图片渲染速度不够快,导致多张图渲染在同一页 ,这时候可以在翻页的时候做一个重新load 当前组建的行为,或者在翻页的时候做一个判定如果没有渲染完成就不能翻页。
画字:
- 画字有好几种,fabric.Text, fabric.IText, fabric.TextBox, 其中IText 和 TextBox 可以做编辑
const createText = (detail, index, scale, type, editedCallback) => { const options = { left: detail.region[0] * scale, top: detail.region[1] * scale, hasControls: false, // 是否展示控制框,就旋转,放大缩小 editable: true, // 是否可编辑 lockMovementX: true, // 拖拽移动的时候固定x轴 lockMovementY: true, // 拖拽移动的时候固定y轴 hoverCursor: 'pointer', // 鼠标移动到文本块的时候,手势变化 textBackgroundColor: 'red', // 文本块背景 key: `${type}_${index}`, // 当前文本块的唯一标志,可有可无 } let fontSize = (detail.region[5] - detail.region[1]) * scale * 0.9; // 字体大小 const text = new fabric.IText(detail.result, {...options, fontSize}); text.on("editing:exited", () => { //editing:exited 是编辑结束之后的事件回调 editedCallback(text.text) }) return text; }
- 在画字的时候如果画的字块有点多,就会出现模糊的情况,这时候禁用缓存就可以 options 里加上 objectCaching: false
Demo:
import React, { useEffect, useRef, useState, useMemo } from 'react';
import { fabric } from 'fabric'
export type DocumentReductionImageProps = {
width: number,
height: number,
orientation: number,
url: string,
activeBoxIndex: string,
regions: any[],
setImageInfo?: (imageInfo: any) => void
updateZoom?: (zoom: number) => void
}
export type ImageInfo = {
width: number | undefined,
height: number | undefined,
}
export type offset = {
x: number | undefined,
y: number | undefined,
}
const DrawFile: React.FC<DocumentReductionImageProps> = (props) => {
const [imageInfo, setImageInfo] = useState<ImageInfo>({width: 0, height: 0})
const canvasRef = useRef<HTMLCanvasElement|null>(null);
const imageOffset = useRef<offset>({x: 0, y: 0})
const canvasWheeled = useRef<number>(0)
const canvas = useMemo(() => {
return new fabric.Canvas(
canvasRef.current,
{
backgroundColor: '#e5e5e5',
selection: false,
width: props.width,
height: props.height,
}
)
}, [canvasRef.current])
const updateImageScale = (isDown) => {
let off = 0.1;
if (isDown) {
off = -0.1;
}
let zoom: number = canvas.getZoom() + off;
if (zoom <= 0.1) {
zoom = 0.1;
}
if (canvasWheeled.current === 0) {
canvasWheeled.current = canvas.getZoom();
}
canvas.setZoom(zoom)
props.updateZoom?.(zoom)
}
//drawImage
useEffect(() => {
if (!canvas) {
return ;
}
const drewPage: fabric.Image[] = []
fabric.Image.fromURL(
props.url,
oImg => {
drewPage.push(oImg)
let newImageInfo = {width: oImg.width, height: oImg.height};
const angle = ((360 - props.orientation) || 0) % 360
if ((angle / 90) % 2) {
newImageInfo = {width: oImg.height, height: oImg.width};
}
const scale = props.width / newImageInfo.width;
if (angle) {
const left = angle == 90 || angle == 180 ? props.width : 0;
const top = angle == 180 || angle == 270 ? scale * newImageInfo.height : 0;
oImg.rotate(angle)
.set('left', left)
.set('top', top)
} else {
oImg.set('top', 0)
}
oImg.scale(scale)
canvas.add(oImg)
setImageInfo(newImageInfo);
props.setImageInfo?.(newImageInfo)
},
{
selectable: false,
hoverCursor: 'default',
top: 0,
left: 0,
}
)
return () => {
drewPage.map(img => {
canvas.remove(img)
})
setImageInfo({width: 0, height: 0});
};
}, [props.url, canvas]);
// 拖拽
useEffect(() => {
// 拖拽
const handleMouseDown = (handle: fabric.IEvent<MouseEvent>) => {
const { e } = handle
const point = {
x: e.pageX,
y: e.pageY,
}
if (canvasWheeled.current != 0) {
const zoomRate = canvas.getZoom() / (canvasWheeled.current);
imageOffset.current = {x: imageOffset.current.x * zoomRate, y: imageOffset.current.y * zoomRate}
canvasWheeled.current = 0;
}
const handleMouseMove = (handle: fabric.IEvent<MouseEvent>) => {
const { e } = handle
const offset = {
x: -1 * (e.pageX - point.x) + imageOffset.current.x,
y: -1 * (e.pageY - point.y) + imageOffset.current.y,
}
canvas.absolutePan(offset)
}
const handleMouseLeave = (handle: fabric.IEvent<MouseEvent>) => {
const { e } = handle
// @ts-ignore
canvas.off('mouse:move', handleMouseMove)
// @ts-ignore
canvas.off('mouse:up', handleMouseLeave)
// @ts-ignore
canvas.off('mouse:out', handleMouseLeave)
imageOffset.current = {
x: -1 * (e.pageX - point.x) + imageOffset.current.x,
y: -1 * (e.pageY - point.y) + imageOffset.current.y,
}
}
canvas.on('mouse:move', handleMouseMove)
canvas.on('mouse:up', handleMouseLeave)
canvas.on('mouse:out', handleMouseLeave)
}
canvas.on('mouse:down', handleMouseDown)
return () => {
// @ts-ignore
canvas.off('mouse:down', handleMouseDown)
}
}, [canvas])
//滚动缩放
useEffect(() => {
if (!canvas) {
return;
}
const handleWheel = (handle: fabric.IEvent<WheelEvent>) => {
const { e } = handle
const isDown = e.deltaY > 0
e.preventDefault();
updateImageScale(isDown)
}
canvas.on('mouse:wheel', handleWheel)
return () => {
// @ts-ignore
canvas.off('mouse:wheel', handleWheel)
}
}, [canvas])
//画框
useEffect(() => {
if (!canvas || !(props.regions?.length > 0) || imageInfo.width === 0) {
return;
}
const scale = props.width / imageInfo.width;
const region = {
left: props.regions[0] * scale,
top: props.regions[1] * scale,
selectable: false,
hoverCursor: 'default',
stroke: '#8BC7FF',
strokeWidth: 1.8,
width: (props.regions[2] - props.regions[0]) * scale,
height: (props.regions[3] - props.regions[1]) * scale,
}
const rect = new fabric.Rect(region)
rect.set('opacity', 0.5)
rect.set('fill', 'transparent')
canvas.add(rect)
return () => {
canvas.remove(rect);
}
}, [props.regions, canvas, props.activeBoxIndex, imageInfo])
//定位activeBoxIndex
useEffect(() => {
if (!props.regions || props.regions.length != 4) {
return;
}
const scale = props.width / imageInfo.width;
const offset = {
x: props.regions[0] * scale * canvas.getZoom() - 100,
y: props.regions[1] * scale * canvas.getZoom() - 100,
}
imageOffset.current = offset
canvas.absolutePan(offset)
canvasWheeled.current = 0;
}, [props.regions])
return (
<div>
<canvas ref={canvasRef}/>
</div>
)
}
export default DrawFile;
官方文档: