欢迎来到飞鸟慕鱼博客,开始您的技术之旅!
当前位置: 首页知识笔记正文

多人在线编辑文档,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);        }    });});

标签:
声明:无特别说明,转载请标明本文来源!