需求分析
之前预览网络摄像头的需求又有了下文,要在视频预览之上进行拖拽生成矩形边框,用于后台算法对区域内容进行一些处理。
当看到需求的第一眼我就想起了 canvas(除了 canvas 貌似也没有其他方案)
思路:
点击绘制按钮进入绘制模式
在 video 上层覆盖 canvas ,添加鼠标事件用于绘制
如果需要绘制多个矩形则需要覆盖两层 canvas(只有一层的话绘制第二个矩形会清除第一个),底层用于渲染历史矩形,上层用于实时绘制矩形,矩形绘制完毕之后添加到历史记录中
鼠标按下时开始绘制,鼠标移动时绘制矩形,鼠标抬起时结束绘制
如果需要撤销,从历史中 pop最后一组数据
代码实践
技术栈:React + Typescript + Umi
模板
<Modal
width={900}
visible={visible}
footer={
<>
{isEdit ? (
<>
<Button onClick={revoke} disabled={historyRect.length <= 0}>
<RollbackOutlined />
</Button>
<Button onClick={save}>
<SaveOutlined />
</Button>
</>
) : null}
<Button onClick={() => setIsEdit(!isEdit)} type="primary">
{isEdit ? '结束绘制' : '开始绘制'}
</Button>
</>
}
destroyOnClose
onCancel={() => {
setVisible(false);
// 关闭弹窗时关闭连接
preview.close();
}}
bodyStyle={{ padding: 0, height: 450, position: 'relative' }}
maskClosable={false}
title={player?.cameraName || 'test'}
>
<video ref={previewRef} autoPlay width="100%" controls />
<canvas
ref={canvasBgRef}
width="900px"
height="450px"
className={styles.canvasBg}
/>
{isEdit ? (
<>
<canvas
ref={canvasRef}
width="900px"
height="450px"
className={styles.canvas}
onMouseDown={startDraw}
onMouseUp={overDraw}
onMouseMove={drawRect}
/>
</>
) : null}
</Modal>
绑定的 class 是用于将 canvas 定位在 video 之上的,这里就不列出来了。
效果如下(摄像头被别人拿去用了,所以这里画面没有了)
进入绘制之后
使用两个 ref 用于获取元素进行 canvas 操作;isEdit 是一个控制操作层 canvas 显示隐藏的 state。
左侧为撤销按钮,能够清除上一步的操作,原理是将历史记录栈的最后一项 pop 出去,其右侧为保存按钮,能够将当前的绘制操作上传至后端服务。
其中绑定的事件如下
/**
* 鼠标落下,开始绘制,记录起始坐标
* @param e
*/
function startDraw(e: SyntheticEvent<HTMLCanvasElement, MouseEvent>): void {
setIsDrawing(true);
setRectInfo({
...rectInfo,
startX: e.nativeEvent.offsetX,
startY: e.nativeEvent.offsetY,
});
}
/**
* 鼠标抬起,结束绘制,记录结束坐标
* @param e
*/
function overDraw(e: SyntheticEvent<HTMLCanvasElement, MouseEvent>): void {
setIsDrawing(false);
setRectInfo({
...rectInfo,
endX: e.nativeEvent.offsetX,
endY: e.nativeEvent.offsetY,
});
const canvas = canvasRef.current;
// 清除当前图层
canvas?.getContext('2d')?.clearRect(0, 0, canvas.width, canvas.height);
}
/**
* 鼠标移动
* @param e
*/
function drawRect(e: SyntheticEvent<HTMLCanvasElement, MouseEvent>): void {
if (!isDrawing) {
return;
}
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
const { startX, startY } = rectInfo;
if (ctx) {
ctx.lineWidth = 2;
ctx.strokeStyle = '#ff0000';
ctx.clearRect(0, 0, canvas!.width, canvas!.height);
ctx.strokeRect(
startX,
startY,
e.nativeEvent.offsetX - startX,
e.nativeEvent.offsetY - startY,
);
}
}
/**
* 撤销上一步绘制
*/
function revoke(): void {
const history = [...historyRect];
history.splice(history.length - 1, 1);
setHistoryRect(history);
}
/**
* 保存绘制
*/
async function save(): Promise<void> {
const success = await saveRects();
/*参数在传递形式写这篇文章的时候还没有确定,这里是只是一个 ajax 请求*/
if (success) {
message.success('保存成功');
}
}
这里事件需要注意,react 中获取的事件并不是原生事件,而是合成事件,其类型如下SyntheticEvent<T = Element, E = Event>
,想要获取原生事件的 offset 需要先取到 nativeEvent 原生事件
看完之后你会发现凭借这些代码并不能实现绘制效果,因为 useState 的 setState 是异步的,所以需要配合 useEffect 才能实时绘制,鼠标事件主要控制数据,canvas 的绘制工作交给 useEffect
useEffect(() => {
// history 发生变化时重绘背景图层
const bgCtx = canvasBgRef.current?.getContext('2d');
if (bgCtx) {
// 擦除全部矩形
bgCtx.clearRect(
0,
0,
canvasBgRef.current!.width,
canvasBgRef.current!.height,
);
bgCtx.lineWidth = 2;
bgCtx.strokeStyle = '#ff0000';
historyRect.forEach((rect) => {
bgCtx.strokeRect(
rect.startX,
rect.startY,
rect.endX - rect.startX,
rect.endY - rect.startY,
);
});
}
}, [historyRect]);
useEffect(() => {
// 绘制信息发生改变时触发, 回执结束时执行, 并且不能是一个点
if (
!isDrawing &&
(rectInfo.startX !== rectInfo.endX || rectInfo.startY !== rectInfo.endY)
) {
setHistoryRect([...historyRect, rectInfo]);
}
}, [rectInfo, isDrawing]);
事件的顺序是鼠标抬起的时候设置矩形的终止坐标,但是同时需要将当前矩形压入历史记录,这两个 setState 同时执行会产生不同步的问题,所以使用绘制状态 + 矩形坐标的组合来控制压栈
最终的实现效果