需求分析
之前预览网络摄像头的需求又有了下文,要在视频预览之上进行拖拽生成矩形边框,用于后台算法对区域内容进行一些处理。
当看到需求的第一眼我就想起了 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 同时执行会产生不同步的问题,所以使用绘制状态+矩形坐标的组合来控制压栈
最终的实现效果