基于 yjs 实现实时在线多人协作的绘画功能
- 支持多客户端实时共享编辑
- 自动同步,离线支持
- 自动合并,自动冲突处理
1. 客户端代码(基于Vue3)
实现绘画功能
<template>
<div style="{width: 100vw; height: 100vh; overflow: hidden;}">
<canvas ref="canvasRef" style="{border: solid 1px red;}" @mousedown="startDrawing" @mousemove="draw"
@mouseup="stopDrawing" @mouseleave="stopDrawing">
</canvas>
</div>
<div style="position: absolute; bottom: 10px; display: flex; justify-content: center; height: 40px; width: 100vw;">
<div style="width: 100px; height: 40px; display: flex; align-items: center; justify-content: center; color: white;"
:style="{ backgroundColor: color }">
<span>当前颜色</span>
</div>
<Button style="width: 100px; height: 40px; margin-left: 10px;" @click="switchMode(DrawType.Point)">画点</Button>
<Button style="width: 100px; height: 40px; margin-left: 10px;" @click="switchMode(DrawType.Line)">直线</Button>
<Button style="width: 100px; height: 40px; margin-left: 10px;" @click="switchMode(DrawType.Draw)">涂鸦</Button>
<Button style="width: 100px; height: 40px; margin-left: 10px;" @click="clearCanvas">清除</Button>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { Button, Modal, Input } from "ant-design-vue";
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { v4 as uuidv4 } from 'uuid';
const canvasRef = ref<null | HTMLCanvasElement>(null);
const ctx = ref<CanvasRenderingContext2D | null>(null);
const drawing = ref(false);
const color = ref<string>("black");
class Point {
x: number = 0.0;
y: number = 0.0;
}
enum DrawType {
None,
Point,
Line,
Draw,
}
const colors = [
"#FF5733", "#33FF57", "#5733FF", "#FF33A2", "#A2FF33",
"#33A2FF", "#FF33C2", "#C2FF33", "#33C2FF", "#FF3362",
"#6233FF", "#FF336B", "#6BFF33", "#33FFA8", "#A833FF",
"#33FFAA", "#AA33FF", "#FFAA33", "#33FF8C", "#8C33FF"
];
// 随机选择一个颜色
function getRandomColor() {
const randomIndex = Math.floor(Math.random() * colors.length);
return colors[randomIndex];
}
class DrawElementProp {
color: string = "black";
}
class DrawElement {
id: string = "";
version: string = "";
type: DrawType = DrawType.None;
geometry: Point[] = [];
properties: DrawElementProp = new DrawElementProp();
}
// 选择的绘画模式
const drawMode = ref<DrawType>(DrawType.Draw);
// 定义变量来跟踪第一个点的坐标和鼠标是否按下
const point = ref<Point | null>(null);
// 创建 ydoc, websocketProvider
const ydoc = new Y.Doc();
// 创建一个 Yjs Map,用于存储绘图数据
const drawingData = ydoc.getMap<DrawElement>('drawingData');
drawingData.observe(event => {
if (ctx.value && canvasRef.value) {
const context = ctx.value!
// 清空 Canvas
context.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
// 遍历绘图数据,绘制点、路径等
drawingData.forEach((data: DrawElement) => {
if (data.type == DrawType.Point) {
context.fillStyle = data.properties.color; // 设置点的填充颜色
context.strokeStyle = data.properties.color; // 设置点的边框颜色
context.beginPath();
context.moveTo(data.geometry[0].x, data.geometry[0].y);
context.arc(data.geometry[0].x, data.geometry[0].y, 2.5, 0, Math.PI * 2); // 创建一个圆形路径
context.fill(); // 填充路径,形成圆点
context.closePath();
} else if (data.type == DrawType.Line) {
context.fillStyle = data.properties.color; // 设置点的填充颜色
context.strokeStyle = data.properties.color; // 设置点的边框颜色
context.beginPath();
// 遍历所有点
data.geometry.forEach((p: Point, index: number) => {
if (index == 0) {
context.moveTo(p.x, p.y);
context.fillRect(p.x, p.y, 5, 5);
} else {
context.lineTo(p.x, p.y);
context.stroke();
context.fillRect(p.x, p.y, 5, 5);
}
})
} else if (data.type == DrawType.Draw) {
context.fillStyle = data.properties.color; // 设置点的填充颜色
context.strokeStyle = data.properties.color; // 设置点的边框颜色
context.beginPath();
// 遍历所有点
data.geometry.forEach((p: Point, index: number) => {
if (index == 0) {
context.moveTo(p.x, p.y);
} else {
context.lineTo(p.x, p.y);
context.stroke();
}
})
} else {
console.log("Invalid draw data", data)
}
})
}
})
const websocketProvider = new WebsocketProvider(
'ws://localhost:8080/ws', 'demo', ydoc
)
onMounted(() => {
if (canvasRef.value) {
// 随机选择一种颜色
color.value = getRandomColor()
canvasRef.value.height = window.innerHeight - 10;
canvasRef.value.width = window.innerWidth;
const context = canvasRef.value.getContext('2d');
if (context) {
ctx.value = context;
context.lineWidth = 5;
context.fillStyle = color.value; // 设置点的填充颜色
context.strokeStyle = color.value; // 设置点的边框颜色
context.lineJoin = 'round';
}
}
window.addEventListener('keydown', handleKeyDown);
});
const handleSaveUserName = () => {
if (userName.value) {
modalOpen.value = false;
}
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
// 重置编号
if (currentID.value) {
currentID.value = "";
}
// 结束路径和绘画
if (drawing.value && ctx.value) {
ctx.value.closePath();
drawing.value = false;
}
}
}
const switchMode = (mode: DrawType) => {
// 重置状态
currentID.value = "";
drawing.value = false;
drawMode.value = mode;
point.value = null
}
// 记录当前路径的编号
const currentID = ref<string>("");
const startDrawing = (e: any) => {
// 获取当前时间的秒级时间戳
const timestampInSeconds = Math.floor(Date.now() / 1000);
// 将秒级时间戳转换为字符串
const version = timestampInSeconds.toString();
if (ctx.value) {
if (drawMode.value === DrawType.Point) {
// 分配编号
currentID.value = uuidv4();
let point: DrawElement = {
id: currentID.value,
version: version,
type: DrawType.Point,
geometry: [{ x: e.clientX, y: e.clientY }],
properties: { color: color.value }
}
drawingData.set(currentID.value, point);
// 重置编号
currentID.value = ""
return
}
if (drawMode.value === DrawType.Line) {
// 分配编号
if (currentID.value == "") {
currentID.value = uuidv4();
}
// 没有正在绘画
if (!drawing.value) {
// 开始绘画
drawing.value = true;
}
// 获取当前线的信息,如果没有则创建
let line: DrawElement | undefined = drawingData.get(currentID.value)
if (line) {
line.version = version;
line.geometry.push({ x: e.clientX, y: e.clientY });
} else {
line = {
id: currentID.value,
version: version,
type: DrawType.Line,
geometry: [{ x: e.clientX, y: e.clientY }],
properties: { color: color.value }
}
}
drawingData.set(currentID.value, line);
return
}
if (drawMode.value === DrawType.Draw) {
// 分配编号
if (currentID.value == "") {
currentID.value = uuidv4();
let path: DrawElement = {
id: currentID.value,
version: version,
type: DrawType.Draw,
geometry: [{ x: e.clientX, y: e.clientY }],
properties: { color: color.value }
}
drawingData.set(currentID.value, path);
}
// 没有正在绘画
if (!drawing.value) {
// 开始绘画
drawing.value = true;
}
}
}
};
const draw = (e: any) => {
if (drawing.value && ctx.value) {
if (drawMode.value === DrawType.Draw) {
// 获取当前线的信息,如果没有则创建
let path: DrawElement | undefined = drawingData.get(currentID.value)
if (path) {
path.geometry.push({ x: e.clientX, y: e.clientY });
drawingData.set(currentID.value, path);
return
}
console.log("error: not found path", currentID.value)
}
}
};
const stopDrawing = () => {
if (drawing.value && ctx.value) {
if (drawMode.value === DrawType.Draw) {
// 鼠标放开时,关闭当前路径绘画
currentID.value = "";
drawing.value = false;
}
}
};
const clearCanvas = () => {
if (canvasRef.value && ctx.value) {
ctx.value.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
drawingData.clear();
}
};
</script>
2. 服务端代码
基于 yjs 的多人协助其实只需要前端,使用 y-webtrc 也可以实现数据共享,但是为了增加一些功能,如权限控制、数据库存储等,需要使用服务端;不考虑复杂功能,我们使用 websocket 进行客户端之间的通信,所以服务端也很简单,实现了 websocket 服务端的功能即可
- 可以使用 yjs 推荐的 y-websocket 的 nodejs 服务
HOST=localhost PORT=8080 npx y-websocket
- 也可以自己实现一个 websocket 服务端,这里选择用 golang 实现一个
// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"net/http"
"github.com/olahol/melody"
)
func main() {
m := melody.New()
m.Config.MessageBufferSize = 65536
m.Config.MaxMessageSize = 65536
m.Upgrader.CheckOrigin = func(r *http.Request) bool { return true }
http.HandleFunc("/ws/demo", func(w http.ResponseWriter, r *http.Request) {
m.HandleRequest(w, r)
})
// 不重要
m.HandleConnect(func(session *melody.Session) {
println("connect")
})
// 不重要
m.HandleDisconnect(func(session *melody.Session) {
println("disconnect")
})
// 不重要
m.HandleClose(func(session *melody.Session, i int, s string) error {
println("close")
return nil
})
// 不重要
m.HandleError(func(session *melody.Session, err error) {
println("error", err.Error())
})
// 不重要
m.HandleMessage(func(s *melody.Session, msg []byte) {
m.Broadcast(msg)
})
// 主要内容,对 yjs doc 的改动内容进行广播到其他客户端
m.HandleMessageBinary(func(s *melody.Session, msg []byte) {
m.BroadcastBinary(msg)
})
http.ListenAndServe(":8080", nil)
}
3. 特殊的 nodejs 客户端,用于保存数据
yjs 在客户端上进行文档冲突处理以及合并,每个客户端都维护着自己的文档,为了使数据能够持久化到文件或者数据库中,需要使用一个客户端作为基准,并且这个客户端对文档应该是只读不改的,运行在服务器上;基于以上考量,我们选择使用 nodejs 实现一个客户端运行在服务器上(如果选用golang的话,没有 yjs 实现的方法可以解析 ydoc 的数据)
nodejs 客户端,只需要连接上 y-websocket 并且当文档更新时,保存数据
const fs = require('fs');
const Y = require('yjs');
const { WebsocketProvider } = require('y-websocket');
const WebSocket = require('websocket').w3cwebsocket;
// 创建 Yjs 文档
const ydoc = new Y.Doc();
const websocketProvider = new WebsocketProvider(
'ws://localhost:8080/ws', 'demo', ydoc, {
WebSocketPolyfill: WebSocket,
})
const drawingData = ydoc.getMap('drawingData');
// 当文档发生更改时,将更改内容打印出来
ydoc.on('update', () => {
console.log('Document updated', ydoc.clientID);
const document = [];
drawingData.forEach((data) => {
document.push(data)
})
// 要写入的文件路径
const filePath = 'doc/data.json';
const fileContent = JSON.stringify(document);
// 使用 fs.writeFile 方法写入文件
fs.writeFile(filePath, fileContent, (err) => {
if (err) {
console.error('save error', err);
} else {
console.log('document saved');
}
});
});