目录

  • 协同画板相关介绍
  • 协同画板实现
  • 协同画板实现效果
  • 协同画板相关难点和解决方案
  • 源码下载

协同画板相关介绍

画板协同:
简单来说就是使用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 不能bindthis.bindMousedown = function () {}; // 解决 eventlistener 不能bindthis.bindMouseup = function () {}; // 解决 eventlistener 不能bindthis.bindTouchMove = function () {}; // 解决 eventlistener 不能bindthis.bindTouchStart = function () {}; // 解决 eventlistener 不能bindthis.bindTouchEnd = function () {}; // 解决 eventlistener 不能bindthis.init();}init() {this.paint.fillStyle = '#fff';this.paint.fillRect(0, 0, this.width, this.height);this.gatherImage();this.bindMousemove = this.onmousemove.bind(this); // 解决 eventlistener 不能bindthis.bindMousedown = this.onmousedown.bind(this);this.bindMouseup = this.onmouseup.bind(this);this.bindTouchMove = this.onTouchMove.bind(this); // 解决 eventlistener 不能bindthis.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-pickerv-model="color"show-alphav-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-popoverplacement="top"width="400"trigger="click"v-if="v.type === 'polygon'"><el-input-numberv-model="sides"controls-position="right"@change="sidesChange":min="3":max="10"></el-input-number><buttonslot="reference"@click="handleClick(v)":class="{ active: currHandle === v.type }">{{ v.name }}</button></el-popover><el-popoverplacement="top"width="400"trigger="click"v-if="v.type === 'lineWidth'"><el-sliderv-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("连接成功:");// 订阅topicclient.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. 书写

  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

Canvas实现网页协同画板相关推荐

  1. HTML5+canvas激流勇进网页游戏源码

    介绍: HTML5+canvas激流勇进网页游戏,游戏玩法:使用左键.右键和上箭头键移动. 网盘下载地址: http://kekewangLuo.cc/qn9O6AvpNW10 图片:

  2. canvas实现pc端画板

    canvas实现pc端画板 代码下载链接:https://download.csdn.net/download/weixin_56131571/15809020 可以设置线条颜色,以及线条粗细 htm ...

  3. 用Canvas为网页添加动态背景

    用Canvas为网页添加动态背景 作者:uedtianji 最近刚刚接到为微信公众帐号"玩转三里屯"制作首页的任务.考虑到页面只在手机中浏览,而且手机对canvas的支持又非常好, ...

  4. Canvas实现网页星空背景粒子动效跟随光标

    目  录 1. 设计思路 2. 星空背景图片 3. 页面代码 4. 效果图 1. 设计思路 1. 利用样式插入星空背景图片: 2.设置窗口改变时自动修改画布大小(自适应): 3. 编写随机函数(随机数 ...

  5. 使用canvas截图网页为图片并解决跨域空白以及模糊问题

    使用canvas截图网页为图片并解决跨域空白以及模糊问题 参考文章: (1)使用canvas截图网页为图片并解决跨域空白以及模糊问题 (2)https://www.cnblogs.com/bububu ...

  6. HTML5基于canvas的网页绘画系统

    绘画是一种在平面上以手工方式临摹自然或非自然,以其达到二维(平面或三维)效果的艺术,在中世纪的欧洲,常把绘画称作"猴子的艺术",因为如同猴子喜欢模仿人类活动一样,绘画也是模仿场景. ...

  7. canvas 制作简易涂鸦画板(教程)

    公司大电视机是安卓系统而且系统,但因为突然无法联网又不允许第三方应用程序,但零时需要画板功能.所以就简单做个画板工具代替一下. 1.在canvas中获取光标坐标 获取坐标的代码很简单: <!DO ...

  8. tkinter Canvas 实现 鼠标手绘画板 功能

    下面代码简单实现了手绘画板功能,其实也是Canvas的教程,后面都给加了注释. 这里通过列表来存储笔画,实现撤销恢复功能,右键有菜单. import tkinter as tk from tkinte ...

  9. html5实现在线动态画板,Javascript HTML5 Canvas实现的一个画板

    DEMO6:自定义画板 浏览器不支持canvas YELLOW RED BLUE GREEN WHITE BLACK 4PX 8PX 16PX EXPORT var canvas = document ...

最新文章

  1. 【C++ 语言】命名空间 ( namespace | 命名空间定义 | 命名空间嵌套 | 域作用符 | 与 include 对比 )
  2. 函数-函数的基本组成
  3. Spring Injection with @Resource, @Autowired and @Inject
  4. IDEA中安装配置Jrebel热部署插件用法笔记
  5. 《企业软件交付:敏捷与高效管理精要》——3.4 企业软件交付的软件工厂方法...
  6. sci写作sci写作模板_有写作错误吗? bibisco简介
  7. 04.通过qss文件设置setStyleSheet
  8. 蜂鸟A20开发板刷 cubietruck 的 SD 卡固件
  9. 学完Java基础后的总结
  10. Python FastAPI 微信公众号后台服务器验证
  11. STM32之字模软件的使用
  12. Android测试工具-Emmagee使用
  13. java计算两点距离,java计算两点间的距离方法总结,java计算两点距离
  14. javascript中关于日期和时间的基础知识
  15. 斯蒂夫乔布斯传札记:第八波
  16. 在对HDFS格式化,执行hadoop namenode -format命令时
  17. 多多参谋:拼多多推广怎么出价?该怎么做好推广?
  18. linux中test命令详解
  19. vue+elementUI同时上传视频和图片并回显
  20. 将两个有序列表合并,并保持顺序(一)

热门文章

  1. 图神经网络基础--图结构数据
  2. Hank的无线802.11学习笔记--part 4
  3. 串口接收不定长数据的几种方式
  4. vscode上直接运行ts
  5. Jmeter使用BeanShell取样器调用Python脚本
  6. Excel中VLOOKUP函数的详细用法(灰常有用,求加精!求加精!)
  7. 《爱上Pandas》系列-你还在用VLookup吗?
  8. Matlab 基于遗传算法优化的VMD信号去噪算法 创新点:基于样本熵作为适应度函数
  9. 实践是检验真理的唯一标准之python array.sum(axis=0)
  10. java超市管理系统_一个简单的基于控制台的超市管理系统(java)