协同画板相关介绍
画板协同:
简单来说就是使用canvas开发一个可以多人共享的画板,都可以在上面作画画板,并且画面同步显示
canvas白板相关使用参考我之前的文章:Canvas网页涂鸦板再次增强版
协同的方式:
相当于创建一个房间,像微信的面对面建群一样,加入房间的用户之间可以进行消息通讯,其中一个客户端发布消息,其他的客户都会被分发消息,而达到的一种消息同步的效果
实现方案:
使用mqtt作为消息订阅分发服务器(参考的江三疯大佬的实现方案是使用 socketio WebRTC:https://juejin.cn/post/6844903811409149965)
mqtt的相关使用可以参考:https://qkongtao.cn/?tag=mqtt
- 固定申请一组username、password,专门用于客户端消息同步建立连接。每个客户端建立连接都使用一个唯一的clientId作为客户端标识(这个唯一标识可以是策略生成的随机数,也可以是客户端自己的唯一标识)
- 通过后台控制房间的管理,创建房间建立连接的时候,必须通过后端发送请求,申请 一个topic,用于消息的发布和订阅。一个topic相当于一个一个房间。
- 在客户端建立一个像微信面对面建群一样的建立房间的功能输入框,旁边添加一个产生随机数策略的按钮,这个按钮产生的随机数就是topic(房间号)。
- 然后点击提交,后台则添加一组默认username、password的topic,客户端则订阅该topic,相当于创建了一个房间。
- 其他机器在输入框输入这个相同的房间号,进行对该主题进行订阅,即可以进行消息的发布和接收。
- 当连接数小于1的时候,自动销毁房间topic。
协同画板实现
- 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
}
- mqtt配置文件 mqttconstant.js
export const MQTT_SERVICE = 'ws://127.0.0.1:8083/mqtt'
export const MQTT_USERNAME = 'admin'
export const MQTT_PASSWORD = '123456'
- 协同画板实现
<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.就相当于固定了客户端加入的房间为一个房间。
协同画板实现效果
- 书写
- 撤回和前进
- 多边形
- 多画板协同
- 新加入客户端同步
协同画板相关难点和解决方案
- 实现实现画板协同,发送消息的时机 解决方案:是通过将canvas的一些列操作,如鼠标按下、移动抬起所触发的事件都封装在Palette类中,每次出发这些事件的时候都会调用回调函数moveCallback,new Palette类的时候,将moveCallback挂在全局对象data中,每次触发moveCallback函数的时候,执行消息的广播操作。
- 每次有新的客户端加入房间时,进行数据同步 解决方案:
- 同步策略:canvas每次操作进行采集图像,记录于imgData[],并且用index全局记录该客户端的操作当前显示的是第几帧 同步数据在发消息的时候每隔2秒进行广播一次,用index进行判断当前数据是否同步 (数据量太大,不可行)
- 画布的保存:目前选择使用base64导出图片数据然后广播,用户进入房间时获取消息将图片进行渲染(方案可行,但是丢失每次操作的记录)
- 将每次操作的数据点存于服务端,服务端进行数据拆包封装,每次新用户加入房间的时候从服务端拿历史数据。(以后尝试,可行性未知)
3. PC端鼠标操作画板和手机端触摸操作事件不一致的问题
解决方案:PC端鼠标操作画板是mousemove、mousedown、mouseup事件;手机触摸事件是touchmove、touchstart、touchend事件。需要分别进行事件触发的处理,canvas的触摸事件参考:
- 多人同时操作画板,画板目前未实现多人同时操作
- 目前画板还比较简单,未实现操作步骤元素化,每个操作结构都可以进行选择拖拽的功能
源码下载
https://gitee.com/KT1205529635/teamborder-master