前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Canvas实现网页协同画板

Canvas实现网页协同画板

作者头像
不愿意做鱼的小鲸鱼
发布2022-09-30 15:59:55
1.7K0
发布2022-09-30 15:59:55
举报
文章被收录于专栏:web全栈web全栈

协同画板相关介绍

画板协同:

简单来说就是使用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
代码语言:javascript
复制
/**
 * 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
代码语言:javascript
复制
export const MQTT_SERVICE = 'ws://127.0.0.1:8083/mqtt'
export const MQTT_USERNAME = 'admin'
export const MQTT_PASSWORD = '123456'
  1. 协同画板实现
代码语言:javascript
复制
<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. 书写
  1. 撤回和前进
  1. 多边形
  1. 多画板协同
  1. 新加入客户端同步

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

  1. 实现实现画板协同,发送消息的时机 解决方案:是通过将canvas的一些列操作,如鼠标按下、移动抬起所触发的事件都封装在Palette类中,每次出发这些事件的时候都会调用回调函数moveCallback,new Palette类的时候,将moveCallback挂在全局对象data中,每次触发moveCallback函数的时候,执行消息的广播操作。
  2. 每次有新的客户端加入房间时,进行数据同步 解决方案:
代码语言:txt
复制
- 同步策略:canvas每次操作进行采集图像,记录于imgData[],并且用index全局记录该客户端的操作当前显示的是第几帧 同步数据在发消息的时候每隔2秒进行广播一次,用index进行判断当前数据是否同步        (数据量太大,不可行)
    - 画布的保存:目前选择使用base64导出图片数据然后广播,用户进入房间时获取消息将图片进行渲染(方案可行,但是丢失每次操作的记录)
    - 将每次操作的数据点存于服务端,服务端进行数据拆包封装,每次新用户加入房间的时候从服务端拿历史数据。(以后尝试,可行性未知)
3.  PC端鼠标操作画板和手机端触摸操作事件不一致的问题
 解决方案:PC端鼠标操作画板是mousemove、mousedown、mouseup事件;手机触摸事件是touchmove、touchstart、touchend事件。需要分别进行事件触发的处理,canvas的触摸事件参考:
  1. 多人同时操作画板,画板目前未实现多人同时操作
  2. 目前画板还比较简单,未实现操作步骤元素化,每个操作结构都可以进行选择拖拽的功能

源码下载

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

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 协同画板相关介绍
  • 协同画板实现
  • 协同画板实现效果
  • 协同画板相关难点和解决方案
    • 源码下载
    领券
    问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档