yjs demo: 多人在线协作画板

编程入门 行业动态 更新时间:2024-10-11 15:15:06

yjs demo: 多人<a href=https://www.elefans.com/category/jswz/34/1770935.html style=在线协作画板"/>

yjs demo: 多人在线协作画板

基于 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!// 清空 Canvascontext.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 服务端的功能即可

  1. 可以使用 yjs 推荐的 y-websocket 的 nodejs 服务
HOST=localhost PORT=8080 npx y-websocket
  1. 也可以自己实现一个 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 mainimport ("net/http""github/olahol/melody"
)func main() {m := melody.New()m.Config.MessageBufferSize = 65536m.Config.MaxMessageSize = 65536m.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');}});
});

更多推荐

yjs demo: 多人在线协作画板

本文发布于:2023-12-06 22:08:59,感谢您对本站的认可!
本文链接:https://www.elefans.com/category/jswz/34/1669105.html
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。
本文标签:在线   画板   yjs   demo

发布评论

评论列表 (有 0 条评论)
草根站长

>www.elefans.com

编程频道|电子爱好者 - 技术资讯及电子产品介绍!