目录

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

协同画板相关介绍

画板协同:
简单来说就是使用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. 通俗解释AWS云服务每个组件的作用
  2. *:教育产品 规范销售
  3. 用AfxExtractSubString()解析复合串
  4. php未登录跳到登陆页面,vue实现未登录跳转到登录页面的方法
  5. python转行it好学吗-想转行学python过来人提醒大家几点
  6. LeetCode Maximum XOR of Two Numbers in an Array(贪心、字典树)
  7. 深度学习核心技术精讲100篇(五十七)- 自动驾驶车会看地图吗?它是如何认路、找准定位的?
  8. 计算机二级java上机_计算机二级JAVA上机试题及答案
  9. python组件的react实现_【React源码解读】- 组件的实现
  10. 数据引用Data References
  11. bxp客户端手工PNP基本过程(转)
  12. 8款超好用的SVG编辑工具用起来
  13. YYF根据学生编号查询学生签到状态
  14. 基于OpenCV 的图像分割
  15. php怎样做艺术字体,用ps打造科幻艺术字体
  16. python opencv 边缘检测 抠图,python和opencv实现抠图
  17. Educational Codeforces Round 92 (Rated for Div. 2) 题解 (A到G)
  18. 我是80后程序员,我支持正版!
  19. 使用hBuilder打包成app扫一扫
  20. 基因组组装(genome assembly)和对应版本的基因注释包(gene annotation packages)

热门文章

  1. C语言中各种基本数据类型所占空间大小(32位)
  2. 在MySQL中创建视图的X种方式
  3. linux指令grep语法,Linux grep 命令详解
  4. 苹果新iPhone发布会9月11日开幕 官宣海报神似西瓜霜
  5. 【Linux】快捷键
  6. 有回路的有穷自动机转化为正规式的方法
  7. 【C语言】案例五十 歌曲管理系统
  8. soul从入门到进阶05——soul-bootstrap数据同步流程
  9. 河南省第五届ACM程序设计竞赛总结
  10. 游戏中的语音聊天方案