Vue

Canvas实现网页协同画板

Nick · 9月9日 · 2022年 · · 本文15105字 · 阅读38分钟52

协同画板相关介绍

画板协同:
简单来说就是使用canvas开发一个可以多人共享的画板,都可以在上面作画画板,并且画面同步显示
canvas白板相关使用参考我之前的文章:Canvas网页涂鸦板再次增强版

协同的方式:
相当于创建一个房间,像微信的面对面建群一样,加入房间的用户之间可以进行消息通讯,其中一个客户端发布消息,其他的客户都会被分发消息,而达到的一种消息同步的效果

实现方案:
使用mqtt作为消息订阅分发服务器(参考的江三疯大佬的实现方案是使用 socketio + WebRTC:https://juejin.cn/post/6844903811409149965
mqtt的相关使用可以参考:https://qkongtao.cn/?tag=mqtt

  1. 固定申请一组username、password,专门用于客户端消息同步建立连接。每个客户端建立连接都使用一个唯一的clientId作为客户端标识(这个唯一标识可以是策略生成的随机数,也可以是客户端自己的唯一标识)
  2. 通过后台控制房间的管理,创建房间建立连接的时候,必须通过后端发送请求,申请 一个topic,用于消息的发布和订阅。一个topic相当于一个一个房间。
  3. 在客户端建立一个像微信面对面建群一样的建立房间的功能输入框,旁边添加一个产生随机数策略的按钮,这个按钮产生的随机数就是topic(房间号)。
  4. 然后点击提交,后台则添加一组默认username、password的topic,客户端则订阅该topic,相当于创建了一个房间。
  5. 其他机器在输入框输入这个相同的房间号,进行对该主题进行订阅,即可以进行消息的发布和接收。
  6. 当连接数小于1的时候,自动销毁房间topic。

协同画板实现

  1. Canvas工具类封装
    palette.js
/**
 * Created by tao on 2022/09/06.
 */
class Palette {
    constructor(canvas, {
        drawType = 'line',
        drawColor = 'rgba(19, 206, 102, 1)',
        lineWidth = 5,
        sides = 3,
        allowCallback,
        moveCallback
    }) {
        this.canvas = canvas;
        this.width = canvas.width; // 宽
        this.height = canvas.height; // 高
        this.paint = canvas.getContext('2d');
        this.isClickCanvas = false; // 是否点击canvas内部
        this.isMoveCanvas = false; // 鼠标是否有移动
        this.imgData = []; // 存储上一次的图像,用于撤回
        this.index = 0; // 记录当前显示的是第几帧
        this.x = 0; // 鼠标按下时的 x 坐标
        this.y = 0; // 鼠标按下时的 y 坐标
        this.last = [this.x, this.y]; // 鼠标按下及每次移动后的坐标
        this.drawType = drawType; // 绘制形状
        this.drawColor = drawColor; // 绘制颜色
        this.lineWidth = lineWidth; // 线条宽度
        this.sides = sides; // 多边形边数
        this.allowCallback = allowCallback || function () {}; // 允许操作的回调
        this.moveCallback = moveCallback || function () {}; // 鼠标移动的回调
        this.bindMousemove = function () {}; // 解决 eventlistener 不能bind
        this.bindMousedown = function () {}; // 解决 eventlistener 不能bind
        this.bindMouseup = function () {}; // 解决 eventlistener 不能bind
        this.bindTouchMove = function () {}; // 解决 eventlistener 不能bind
        this.bindTouchStart = function () {}; // 解决 eventlistener 不能bind
        this.bindTouchEnd = function () {}; // 解决 eventlistener 不能bind
        this.init();
    }
    init() {
        this.paint.fillStyle = '#fff';
        this.paint.fillRect(0, 0, this.width, this.height);
        this.gatherImage();
        this.bindMousemove = this.onmousemove.bind(this); // 解决 eventlistener 不能bind
        this.bindMousedown = this.onmousedown.bind(this);
        this.bindMouseup = this.onmouseup.bind(this);
        this.bindTouchMove = this.onTouchMove.bind(this); // 解决 eventlistener 不能bind
        this.bindTouchStart = this.onTouchStart.bind(this);
        this.bindTouchEnd = this.onTouchEnd.bind(this);
        this.canvas.addEventListener('mousedown', this.bindMousedown);
        document.addEventListener('mouseup', this.bindMouseup);
        this.canvas.addEventListener('touchstart', this.bindTouchStart);
        document.addEventListener('touchend', this.bindTouchEnd);
    }
    onmousedown(e) { // 鼠标按下
        this.isClickCanvas = true;
        this.x = e.offsetX;
        this.y = e.offsetY;
        this.last = [this.x, this.y];
        this.canvas.addEventListener('mousemove', this.bindMousemove);
    }
    gatherImage() { // 采集图像
        this.imgData = this.imgData.slice(0, this.index + 1); // 每次鼠标抬起时,将储存的imgdata截取至index处
        let imgData = this.paint.getImageData(0, 0, this.width, this.height);
        this.imgData.push(imgData);
        this.index = this.imgData.length - 1; // 储存完后将 index 重置为 imgData 最后一位
        this.allowCallback(this.index > 0, this.index < this.imgData.length - 1);
    }
    reSetImage() { // 重置为上一帧
        this.paint.clearRect(0, 0, this.width, this.height);
        if (this.imgData.length >= 1) {
            this.paint.putImageData(this.imgData[this.index], 0, 0);
        }
    }
    onmousemove(e) { // 鼠标移动
        this.isMoveCanvas = true;
        let endx = e.offsetX;
        let endy = e.offsetY;
        let width = endx - this.x;
        let height = endy - this.y;
        let now = [endx, endy]; // 当前移动到的位置
        switch (this.drawType) {
            case 'line': {
                let params = [this.last, now, this.lineWidth, this.drawColor];
                this.moveCallback('line', ...params);
                this.line(...params);
            }
            break;
        case 'rect': {
            let params = [this.x, this.y, width, height, this.lineWidth, this.drawColor];
            this.moveCallback('rect', ...params);
            this.rect(...params);
        }
        break;
        case 'polygon': {
            let params = [this.x, this.y, this.sides, width, height, this.lineWidth, this.drawColor];
            this.moveCallback('polygon', ...params);
            this.polygon(...params);
        }
        break;
        case 'arc': {
            let params = [this.x, this.y, width, height, this.lineWidth, this.drawColor];
            this.moveCallback('arc', ...params);
            this.arc(...params);
        }
        break;
        case 'eraser': {
            let params = [endx, endy, this.width, this.height, this.lineWidth];
            this.moveCallback('eraser', ...params);
            this.eraser(...params);
        }
        break;
        }
    }
    onmouseup() { // 鼠标抬起
        if (this.isClickCanvas) {
            this.isClickCanvas = false;
            this.canvas.removeEventListener('mousemove', this.bindMousemove);
            if (this.isMoveCanvas) { // 鼠标没有移动不保存
                this.isMoveCanvas = false;
                this.moveCallback('gatherImage');
                this.gatherImage();
            }
        }
    }

    onTouchStart(e) { //触控按下
        console.log('e :>> ', e);
        this.clearDefaultEvent(e)
        this.isClickCanvas = true;
        this.x = e.changedTouches[0].clientX - this.canvas.getBoundingClientRect().left;
        this.y = e.changedTouches[0].clientY - this.canvas.getBoundingClientRect().top;
        this.last = [this.x, this.y];
        this.canvas.addEventListener('touchmove', this.bindTouchMove);
    }
    onTouchEnd(e) { //触控抬起
        this.clearDefaultEvent(e)
        if (this.isClickCanvas) {
            this.isClickCanvas = false;
            this.canvas.removeEventListener('touchmove', this.bindTouchMove);
            if (this.isMoveCanvas) { // 鼠标没有移动不保存
                this.isMoveCanvas = false;
                this.moveCallback('gatherImage');
                this.gatherImage();
            }
        }
    }
    onTouchMove(e) { //触控移动
        this.clearDefaultEvent(e)
        this.isMoveCanvas = true;
        let endx = e.changedTouches[0].clientX - this.canvas.getBoundingClientRect().left;
        let endy = e.changedTouches[0].clientY - this.canvas.getBoundingClientRect().top;
        let width = endx - this.x;
        let height = endy - this.y;
        let now = [endx, endy]; // 当前移动到的位置
        switch (this.drawType) {
            case 'line': {
                let params = [this.last, now, this.lineWidth, this.drawColor];
                this.moveCallback('line', ...params);
                this.line(...params);
            }
            break;
        case 'rect': {
            let params = [this.x, this.y, width, height, this.lineWidth, this.drawColor];
            this.moveCallback('rect', ...params);
            this.rect(...params);
        }
        break;
        case 'polygon': {
            let params = [this.x, this.y, this.sides, width, height, this.lineWidth, this.drawColor];
            this.moveCallback('polygon', ...params);
            this.polygon(...params);
        }
        break;
        case 'arc': {
            let params = [this.x, this.y, width, height, this.lineWidth, this.drawColor];
            this.moveCallback('arc', ...params);
            this.arc(...params);
        }
        break;
        case 'eraser': {
            let params = [endx, endy, this.width, this.height, this.lineWidth];
            this.moveCallback('eraser', ...params);
            this.eraser(...params);
        }
        break;
        }
    }
    line(last, now, lineWidth, drawColor) { // 绘制线性
        this.paint.beginPath();
        this.paint.lineCap = "round"; // 设定线条与线条间接合处的样式
        this.paint.lineJoin = "round";
        this.paint.lineWidth = lineWidth;
        this.paint.strokeStyle = drawColor;
        this.paint.moveTo(last[0], last[1]);
        this.paint.lineTo(now[0], now[1]);
        this.paint.closePath();
        this.paint.stroke(); // 进行绘制
        this.last = now;
    }
    rect(x, y, width, height, lineWidth, drawColor) { // 绘制矩形
        this.reSetImage();
        this.paint.lineWidth = lineWidth;
        this.paint.strokeStyle = drawColor;
        this.paint.strokeRect(x, y, width, height);
    }
    polygon(x, y, sides, width, height, lineWidth, drawColor) { // 绘制多边形
        this.reSetImage();
        let n = sides;
        let ran = 360 / n;
        let rn = Math.sqrt(Math.pow(width, 2) + Math.pow(height, 2));
        this.paint.beginPath();
        this.paint.strokeStyle = drawColor;
        this.paint.lineWidth = lineWidth;
        for (let i = 0; i < n; i++) {
            this.paint.lineTo(x + Math.sin((i * ran + 45) * Math.PI / 180) * rn, y + Math.cos((i * ran + 45) * Math.PI / 180) * rn);
        }
        this.paint.closePath();
        this.paint.stroke();
    }
    arc(x, y, width, height, lineWidth, drawColor) { // 绘制圆形
        this.reSetImage();
        this.paint.beginPath();
        this.paint.lineWidth = lineWidth;
        let r = Math.sqrt(Math.pow(width, 2) + Math.pow(height, 2));
        this.paint.arc(x, y, r, 0, Math.PI * 2, false);
        this.paint.strokeStyle = drawColor;
        this.paint.closePath();
        this.paint.stroke();
    }
    eraser(endx, endy, width, height, lineWidth) { // 橡皮擦
        this.paint.save();
        this.paint.beginPath();
        this.paint.arc(endx, endy, lineWidth / 2, 0, 2 * Math.PI);
        this.paint.closePath();
        this.paint.clip();
        this.paint.clearRect(0, 0, width, height);
        this.paint.fillStyle = '#fff';
        this.paint.fillRect(0, 0, width, height);
        this.paint.restore();
    }
    cancel() { // 撤回
        if (--this.index < 0) {
            this.index = 0;
            return;
        }
        this.allowCallback(this.index > 0, this.index < this.imgData.length - 1);
        this.paint.putImageData(this.imgData[this.index], 0, 0);
    }
    go() { // 前进
        if (++this.index > this.imgData.length - 1) {
            this.index = this.imgData.length - 1;
            return;
        }
        this.allowCallback(this.index > 0, this.index < this.imgData.length - 1);
        this.paint.putImageData(this.imgData[this.index], 0, 0);
    }
    clear() { // 清屏
        this.imgData = [];
        this.paint.clearRect(0, 0, this.width, this.height);
        this.paint.fillStyle = '#fff';
        this.paint.fillRect(0, 0, this.width, this.height);
        this.gatherImage();
    }
    changeWay({
        type,
        color,
        lineWidth,
        sides
    }) { // 绘制条件
        this.drawType = type !== 'color' && type || this.drawType; // 绘制形状
        this.drawColor = color || this.drawColor; // 绘制颜色
        this.lineWidth = lineWidth || this.lineWidth; // 线宽
        this.sides = sides || this.sides; // 边数
    }
    destroy() {
        this.clear();
        this.canvas.removeEventListener('mousedown', this.bindMousedown);
        document.removeEventListener('mouseup', this.bindMouseup);
        this.canvas.removeEventListener('touchstart', this.bindTouchStart);
        document.removeEventListener('touchend', this.bindTouchEnd);
        this.canvas = null;
        this.paint = null;
    }
    clearDefaultEvent(e) {
        e.preventDefault()
        e.stopPropagation()
    }
}
export {
    Palette
}
  1. mqtt配置文件
    mqttconstant.js
export const MQTT_SERVICE = 'ws://127.0.0.1:8083/mqtt'
export const MQTT_USERNAME = 'admin'
export const MQTT_PASSWORD = '123456'
  1. 协同画板实现
<template>
  <div>
    <div>测试mqtt连接</div>
    <el-button type="primary" size="default" @click="printPatlette"
      >消息发布</el-button
    >
    <div class="video-container">
      <div>
        <ul>
          <li v-for="v in handleList" :key="v.type">
            <el-color-picker
              v-model="color"
              show-alpha
              v-if="v.type === 'color'"
              @change="colorChange"
            ></el-color-picker>
            <button
              @click="handleClick(v)"
              v-if="!['color', 'lineWidth', 'polygon'].includes(v.type)"
              :class="{ active: currHandle === v.type }"
            >
              {{ v.name }}
            </button>
            <el-popover
              placement="top"
              width="400"
              trigger="click"
              v-if="v.type === 'polygon'"
            >
              <el-input-number
                v-model="sides"
                controls-position="right"
                @change="sidesChange"
                :min="3"
                :max="10"
              ></el-input-number>
              <button
                slot="reference"
                @click="handleClick(v)"
                :class="{ active: currHandle === v.type }"
              >
                {{ v.name }}
              </button>
            </el-popover>
            <el-popover
              placement="top"
              width="400"
              trigger="click"
              v-if="v.type === 'lineWidth'"
            >
              <el-slider
                v-model="lineWidth"
                :max="20"
                @change="lineWidthChange"
              ></el-slider>
              <button slot="reference">
                {{ v.name }} <i>{{ lineWidth + "px" }}</i>
              </button>
            </el-popover>
          </li>
        </ul>
        <div>
          <h5>画板</h5>
          <div class="boardBox" @touchmove.prevent>
            <canvas width="600" height="400" id="canvas" ref="canvas"></canvas>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import mqtt from "mqtt";
import { Palette } from "../utils/palette";
import {
  MQTT_SERVICE,
  MQTT_USERNAME,
  MQTT_PASSWORD,
} from "../utils/mqttconstant.js";
var client;
// mqtt连接信息
const options = {
  connectTimeout: 40000,
  clientId: "mqttjs_" + Math.random().toString(16).substr(2, 8),
  username: MQTT_USERNAME,
  password: MQTT_PASSWORD,
  clean: false,
};
client = mqtt.connect(MQTT_SERVICE, options);
export default {
  name: "mqttPalette",
  data() {
    return {
      topic: "mqttjsDemo",
      // **************************画板相关*************************
      handleList: [
        { name: "圆", type: "arc" },
        { name: "线条", type: "line" },
        { name: "矩形", type: "rect" },
        { name: "多边形", type: "polygon" },
        { name: "橡皮擦", type: "eraser" },
        { name: "撤回", type: "cancel" },
        { name: "前进", type: "go" },
        { name: "清屏", type: "clear" },
        { name: "线宽", type: "lineWidth" },
        { name: "颜色", type: "color" },
      ],
      color: "rgba(19, 206, 102, 1)",
      currHandle: "line",
      lineWidth: 5,
      palette: null, // 画板
      allowCancel: true,
      allowGo: true,
      sides: 3,
      channel: null,
      messageList: [],
    };
  },
  created() {
    this.$nextTick(() => {
      this.initMqttConnect();
      this.initPalette();
    });
  },
  methods: {
    /************************** 画板相关 ***************************/
    // 初始化画板
    initPalette() {
      this.palette = new Palette(this.$refs["canvas"], {
        drawColor: this.color,
        drawType: this.currHandle,
        lineWidth: this.lineWidth,
        allowCallback: this.allowCallback,
        moveCallback: this.moveCallback,
      });
    },
    sidesChange() {
      // 改变多边形边数
      this.palette.changeWay({ sides: this.sides });
    },
    colorChange() {
      // 改变颜色
      this.palette.changeWay({ color: this.color });
    },
    lineWidthChange() {
      // 改变线宽
      this.palette.changeWay({ lineWidth: this.lineWidth });
    },
    handleClick(v) {
      // 操作按钮
      if (["cancel", "go", "clear"].includes(v.type)) {
        this.moveCallback(v.type);
        this.palette[v.type]();
        this.syncCanvas();
        return;
      }
      // 更换画笔
      this.palette.changeWay({ type: v.type });
      if (["color", "lineWidth"].includes(v.type)) return;
      this.currHandle = v.type;
    },
    allowCallback(cancel, go) {
      this.allowCancel = !cancel;
      this.allowGo = !go;
    },
    moveCallback(...arr) {
      // 发送广播消息(每次move等操作都会调用该回调函数)
      console.log("arr :>> ", arr);
      this.send(arr);
    },
    // 发送消息
    send(arr) {
      arr.splice(1, 0, options.clientId);
      this.sendMessage(this.topic, arr);
      // 每次操作完成之后同步当前画面
      if (arr[0] == "gatherImage") {
        this.syncCanvas();
      }
    },

    syncCanvas() {
      var canvasData = {
        dataURL: this.palette.canvas.toDataURL("image/jpeg", 0.6),
        timestamp: Date.now(),
      };
      // 设置消息保留
      client.publish(this.topic, JSON.stringify(canvasData), {
        qos: 1,
        retain: 1,
      });
    },

    // 打印当前画板
    printPatlette() {
      console.log("this.palette :>> ", this.palette);
    },
    /*==============================画板相关============================*/

    /********************************mqtt相关******************************/
    initMqttConnect() {
      // mqtt连接
      client.on("connect", () => {
        console.log("连接成功:");
        // 订阅topic
        client.subscribe(this.topic, { qos: 1 }, (error) => {
          if (!error) {
            console.log("订阅成功");
          } else {
            console.log("订阅失败");
          }
        });
      });
      // 接收消息处理
      client.on("message", (topic, message) => {
        // 同步房间(topic)画面
        if (
          JSON.parse(message.toString()).dataURL != undefined &&
          this.palette.imgData.length < 2
        ) {
          let img = new Image();
          img.src = JSON.parse(message.toString()).dataURL;
          img.onload = () => {
            document
              .getElementById("canvas")
              .getContext("2d")
              .drawImage(img, 0, 0);
          };
        }
        // 同步操作消息
        else if (Array.isArray(JSON.parse(message.toString()))) {
          let [type, clientId, ...arr] = JSON.parse(message.toString());
          if (clientId != options.clientId) {
            this.palette[type](...arr);
          }
        } else {
          // 其他消息
          this.messageList.push(JSON.parse(message.toString()));
        }
      });
      // 断开发起重连
      client.on("reconnect", (error) => {
        console.log("正在重连:", error);
      });
      // 链接异常处理
      client.on("error", (error) => {
        console.log("连接失败:", error);
      });
    },
    // 发送消息
    sendMessage(topic, message) {
      client.publish(topic, JSON.stringify(message));
    },
    subMessage() {
      this.sendMessage(this.topic, "撒西不理达纳");
    },
    /*============================mqtt相关===============================*/
  },
};
</script>
<style lang="scss" scoped>
.video-container {
  margin-top: 50px;
  display: flex;
  justify-content: center;
  > div:first-child {
    display: flex;
    justify-content: flex-start;
    margin-right: 50px;
    canvas {
      // touch-action: none;
      border: 1px solid #000;
    }
    ul {
      text-align: left;
    }
  }
  > div:last-child {
    .chat {
      width: 500px;
      height: 260px;

      border: 1px solid #000;
      text-align: left;
      padding: 5px;
      box-sizing: border-box;
      .mes {
        font-size: 14px;
      }
    }
    textarea {
      width: 500px;
      height: 60px;
      resize: none;
    }
  }
}
</style>

注意:目前该demo是固定了mqtt的topic为:mqttjsDemo.就相当于固定了客户端加入的房间为一个房间。

协同画板实现效果

  1. 书写
    Canvas实现网页协同画板-左眼会陪右眼哭の博客

  2. 撤回和前进
    Canvas实现网页协同画板-左眼会陪右眼哭の博客

  3. 多边形
    Canvas实现网页协同画板-左眼会陪右眼哭の博客

  4. 多画板协同
    Canvas实现网页协同画板-左眼会陪右眼哭の博客

  5. 新加入客户端同步
    Canvas实现网页协同画板-左眼会陪右眼哭の博客

协同画板相关难点和解决方案

  1. 实现实现画板协同,发送消息的时机
    解决方案:是通过将canvas的一些列操作,如鼠标按下、移动抬起所触发的事件都封装在Palette类中,每次出发这些事件的时候都会调用回调函数moveCallback,new Palette类的时候,将moveCallback挂在全局对象data中,每次触发moveCallback函数的时候,执行消息的广播操作。

  2. 每次有新的客户端加入房间时,进行数据同步
    解决方案:

    • 同步策略:canvas每次操作进行采集图像,记录于imgData[],并且用index全局记录该客户端的操作当前显示的是第几帧
      同步数据在发消息的时候每隔2秒进行广播一次,用index进行判断当前数据是否同步 (数据量太大,不可行)
    • 画布的保存:目前选择使用base64导出图片数据然后广播,用户进入房间时获取消息将图片进行渲染(方案可行,但是丢失每次操作的记录)
    • 将每次操作的数据点存于服务端,服务端进行数据拆包封装,每次新用户加入房间的时候从服务端拿历史数据。(以后尝试,可行性未知)
  3. PC端鼠标操作画板和手机端触摸操作事件不一致的问题
    解决方案:PC端鼠标操作画板是mousemove、mousedown、mouseup事件;手机触摸事件是touchmove、touchstart、touchend事件。需要分别进行事件触发的处理,canvas的触摸事件参考:移动web触摸事件总结。(上述的Palette工具类中已加入了触摸事件的处理,但是仍有多点触摸的事件未进行处理)

  4. 多人同时操作画板,画板目前未实现多人同时操作

  5. 目前画板还比较简单,未实现操作步骤元素化,每个操作结构都可以进行选择拖拽的功能

源码下载

https://gitee.com/KT1205529635/teamborder-master

0 条回应
在线人数:1人 来访统计
说谎
林宥嘉
隐藏