• Canvas实现网页协同画板


    协同画板相关介绍

    画板协同:
    简单来说就是使用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
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    • 149
    • 150
    • 151
    • 152
    • 153
    • 154
    • 155
    • 156
    • 157
    • 158
    • 159
    • 160
    • 161
    • 162
    • 163
    • 164
    • 165
    • 166
    • 167
    • 168
    • 169
    • 170
    • 171
    • 172
    • 173
    • 174
    • 175
    • 176
    • 177
    • 178
    • 179
    • 180
    • 181
    • 182
    • 183
    • 184
    • 185
    • 186
    • 187
    • 188
    • 189
    • 190
    • 191
    • 192
    • 193
    • 194
    • 195
    • 196
    • 197
    • 198
    • 199
    • 200
    • 201
    • 202
    • 203
    • 204
    • 205
    • 206
    • 207
    • 208
    • 209
    • 210
    • 211
    • 212
    • 213
    • 214
    • 215
    • 216
    • 217
    • 218
    • 219
    • 220
    • 221
    • 222
    • 223
    • 224
    • 225
    • 226
    • 227
    • 228
    • 229
    • 230
    • 231
    • 232
    • 233
    • 234
    • 235
    • 236
    • 237
    • 238
    • 239
    • 240
    • 241
    • 242
    • 243
    • 244
    • 245
    • 246
    • 247
    • 248
    • 249
    • 250
    • 251
    • 252
    • 253
    • 254
    • 255
    • 256
    • 257
    • 258
    • 259
    • 260
    • 261
    • 262
    • 263
    • 264
    • 265
    • 266
    • 267
    • 268
    • 269
    • 270
    • 271
    • 272
    • 273
    • 274
    • 275
    • 276
    • 277
    • 278
    • 279
    • 280
    • 281
    • 282
    • 283
    • 284
    • 285
    • 286
    • 287
    • 288
    • 289
    • 290
    • 291
    • 292
    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
    • 2
    • 3
    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>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    • 149
    • 150
    • 151
    • 152
    • 153
    • 154
    • 155
    • 156
    • 157
    • 158
    • 159
    • 160
    • 161
    • 162
    • 163
    • 164
    • 165
    • 166
    • 167
    • 168
    • 169
    • 170
    • 171
    • 172
    • 173
    • 174
    • 175
    • 176
    • 177
    • 178
    • 179
    • 180
    • 181
    • 182
    • 183
    • 184
    • 185
    • 186
    • 187
    • 188
    • 189
    • 190
    • 191
    • 192
    • 193
    • 194
    • 195
    • 196
    • 197
    • 198
    • 199
    • 200
    • 201
    • 202
    • 203
    • 204
    • 205
    • 206
    • 207
    • 208
    • 209
    • 210
    • 211
    • 212
    • 213
    • 214
    • 215
    • 216
    • 217
    • 218
    • 219
    • 220
    • 221
    • 222
    • 223
    • 224
    • 225
    • 226
    • 227
    • 228
    • 229
    • 230
    • 231
    • 232
    • 233
    • 234
    • 235
    • 236
    • 237
    • 238
    • 239
    • 240
    • 241
    • 242
    • 243
    • 244
    • 245
    • 246
    • 247
    • 248
    • 249
    • 250
    • 251
    • 252
    • 253
    • 254
    • 255
    • 256
    • 257
    • 258
    • 259
    • 260
    • 261
    • 262
    • 263
    • 264
    • 265
    • 266
    • 267
    • 268
    • 269
    • 270
    • 271
    • 272
    • 273
    • 274
    • 275
    • 276
    • 277
    • 278
    • 279
    • 280
    • 281
    • 282
    • 283
    • 284
    • 285
    • 286
    • 287
    • 288
    • 289
    • 290
    • 291
    • 292
    • 293
    • 294
    • 295
    • 296
    • 297
    • 298

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

    协同画板实现效果

    1. 书写
      在这里插入图片描述

    2. 撤回和前进
      在这里插入图片描述

    3. 多边形
      在这里插入图片描述

    4. 多画板协同
      在这里插入图片描述

    5. 新加入客户端同步
      在这里插入图片描述

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

    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

  • 相关阅读:
    Python pyenv install 下载安装慢(失败)完美解决
    经典算法之插入排序(InsertionSort)
    SpringCloud: ribbon自定义负载均衡策略
    SettingsProvider
    vue大型电商项目尚品汇(前台篇)day05终结篇
    Excel 智能填充
    白杨SEO:有技能专长的人想要做好知识付费的核心是什么?
    Delta Lake 是什么?
    438. 找到字符串中所有字母异位词
    电商行业:全链路监测广告投放效果,用数据驱动业务增长
  • 原文地址:https://blog.csdn.net/qq_42038623/article/details/126782222