多人在线编辑文档,steam平台多人在线
墨初 知识笔记 61阅读
基于 yjs 实现实时在线多人协作的绘画功能
支持多客户端实时共享编辑自动同步离线支持自动合并自动冲突处理 1. 客户端代码基于Vue3实现绘画功能

<template> <div style{width: 100vw; height: 100vh; overflow: hidden;}> <canvas refcanvasRef style{border: solid 1px red;} mousedownstartDrawing mousemovedraw mouseupstopDrawing mouseleavestopDrawing> </canvas> </div> <div styleposition: absolute; bottom: 10px; display: flex; justify-content: center; height: 40px; width: 100vw;> <div stylewidth: 100px; height: 40px; display: flex; align-items: center; justify-content: center; color: white; :style{ backgroundColor: color }> <span>当前颜色</span> </div> <Button stylewidth: 100px; height: 40px; margin-left: 10px; clickswitchMode(DrawType.Point)>画点</Button> <Button stylewidth: 100px; height: 40px; margin-left: 10px; clickswitchMode(DrawType.Line)>直线</Button> <Button stylewidth: 100px; height: 40px; margin-left: 10px; clickswitchMode(DrawType.Draw)>涂鸦</Button> <Button stylewidth: 100px; height: 40px; margin-left: 10px; clickclearCanvas>清除</Button> </div></template> <script setup langts>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, websocketProviderconst 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 服务HOSTlocalhost PORT8080 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 mainimport (net/httpgithub.com/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); } });});
标签: