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

  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 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');
        }
    });
});

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值