需求分析

之前预览网络摄像头的需求又有了下文,要在视频预览之上进行拖拽生成矩形边框,用于后台算法对区域内容进行一些处理。

当看到需求的第一眼我就想起了 canvas(除了 canvas 貌似也没有其他方案)

思路:

  • 点击绘制按钮进入绘制模式

  • 在 video 上层覆盖 canvas ,添加鼠标事件用于绘制

  • 如果需要绘制多个矩形则需要覆盖两层 canvas(只有一层的话绘制第二个矩形会清除第一个),底层用于渲染历史矩形,上层用于实时绘制矩形,矩形绘制完毕之后添加到历史记录中
  • 鼠标按下时开始绘制,鼠标移动时绘制矩形,鼠标抬起时结束绘制
  • 如果需要撤销,从历史中 pop最后一组数据

QQ20211219-152525@2x

代码实践

技术栈: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 之上的,这里就不列出来了。

效果如下(摄像头被别人拿去用了,所以这里画面没有了)

image-20211220145500691

进入绘制之后

image-20211220150028579

使用两个 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 同时执行会产生不同步的问题,所以使用绘制状态 + 矩形坐标的组合来控制压栈

最终的实现效果

final-view


前端小白