yjs demo: 多人在线协作画板

基于 yjs 实现实时在线多人协作的绘画功能

  • 支持多客户端实时共享编辑
  • 自动同步,离线支持
  • 自动合并,自动冲突处理

1. 客户端代码(基于Vue3)

实现绘画功能

javascript 复制代码
<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 服务
bash 复制代码
HOST=localhost PORT=8080 npx y-websocket
  1. 也可以自己实现一个 websocket 服务端,这里选择用 golang 实现一个
go 复制代码
// 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 并且当文档更新时,保存数据

javascript 复制代码
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');
        }
    });
});
相关推荐
不老刘17 小时前
LiveKit 本地部署全流程指南(含 HTTPS/WSS)
golang·实时音视频·livekit
崔庆才丨静觅19 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606120 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了20 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅20 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅20 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅21 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment21 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅21 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊21 小时前
jwt介绍
前端