yjs demo: 多人在线协作画板

news2024/12/26 23:48:34

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1116768.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Matlab遗传算法工具箱——一个例子搞懂遗传算法

解决问题 我们一般使用遗传算法是用来处理最优解问题的&#xff0c;下面是一个最优解问题的例子 打开遗传算法工具箱 ①在Matlab界面找到应用程序选项&#xff0c;点击应用程序(英文版的Matlab可以点击App选项) ②找到Optimization工具箱&#xff0c;点击打开 创建所需要…

[SQL开发笔记]INSERT INTO 语句:将新记录插入到数据库表中

目前&#xff0c;向数据库插入数据是数据管理的重要环节&#xff0c;它可以将数据长期保存、共享访问、保证数据的完整性和安全性&#xff0c;同时也是进行数据检索和分析的基础。其中&#xff0c;INSERT INTO 语句是SQL&#xff08;结构化查询语言&#xff09;中用于向数据库表…

【王道代码】【2.3链表】d1

关键字&#xff1a; 递归删除x&#xff1b;删除所有x&#xff1b;递归反向输出&#xff1b;删除最小结点&#xff08;2组指针&#xff09;&#xff1b;原地逆置&#xff1b;使递增有序

代码随想录二刷 Day 44

01背包问题二维做法先遍历背包或者物品都可以&#xff0c;然后是前序遍历&#xff1b; 一维做法一定先遍历物品然后遍历背包&#xff0c;遍历背包的时候是后序遍历&#xff1b;一维做法还是有点难理解&#xff0c;其实就是后面的数字还是要从前面的推导出来&#xff0c;但是如…

全球3小时气象数据集GLDAS Noah Land Surface Model L4 3 hourly 0.25 x 0.25 degree V2.1

简介 全球3小时气象数据集&#xff08;GLDAS Noah Land Surface Model L4 3 hourly 0.25 x 0.25 degree V2.1&#xff0c;简称GLDAS_NOAH025_3H 2.1&#xff09;&#xff0c;时空分辨率分别为3小时、0.25度。该数据产品于2020年1月重新处理&#xff0c;代替之前版本。前言 – …

RunnerGo 支持UI自动化的测试平台

RunnerGo提供从API管理到API性能再到可视化的API自动化、UI自动化测试功能模块&#xff0c;覆盖了整个产品测试周期。 RunnerGo UI自动化基于Selenium浏览器自动化方案构建&#xff0c;内嵌高度可复用的测试脚本&#xff0c;测试团队无需复杂的代码编写即可开展低代码的自动化…

基于侏儒猫鼬优化的BP神经网络(分类应用) - 附代码

基于侏儒猫鼬优化的BP神经网络&#xff08;分类应用&#xff09; - 附代码 文章目录 基于侏儒猫鼬优化的BP神经网络&#xff08;分类应用&#xff09; - 附代码1.鸢尾花iris数据介绍2.数据集整理3.侏儒猫鼬优化BP神经网络3.1 BP神经网络参数设置3.2 侏儒猫鼬算法应用 4.测试结果…

Oracle监听服务启动后停止

问题 解决办法 找到listener.ora文件,箭头指的地方&#xff0c;host改为localhost 如何找到listener.ora 其中1522端口&#xff0c;是我新增的监听服务。之前这个host是一个固定的ip地址&#xff0c;我更换网络环境后&#xff0c;ip地址变了&#xff0c;所以导致监听启动失败。…

基于白鲸优化的BP神经网络(分类应用) - 附代码

基于白鲸优化的BP神经网络&#xff08;分类应用&#xff09; - 附代码 文章目录 基于白鲸优化的BP神经网络&#xff08;分类应用&#xff09; - 附代码1.鸢尾花iris数据介绍2.数据集整理3.白鲸优化BP神经网络3.1 BP神经网络参数设置3.2 白鲸算法应用 4.测试结果&#xff1a;5.M…

【Python Numpy教程】numpy数据类型

文章目录 前言一、安装numpy包二、numpy的数据类型2.1 NumPy数据类型概述类型类型字符代码 三、创建数据类型对象3.1 numpy.dtype介绍3.2 示例代码&#xff1a; 总结 前言 NumPy是Python中最常用的科学计算库之一&#xff0c;它提供了高性能的多维数组对象&#xff08;ndarray…

CSS 滚动驱动动画 timeline-scope

timeline-scope 语法兼容性 timeline-scope 看到 scope 就知道这个属性是和范围有关, 没错, timeline-scope 就是用来修改一个具名时间线(named animation timeline)的范围. 我们介绍过的两种时间线 scroll progress timeline 和 view progress timeline, 使用这两种时间线(通…

BAT034:批处理打开电脑常用功能面板

引言&#xff1a;编写批处理程序&#xff0c;输入相应功能序号&#xff0c;实现打开打开百度搜索、启动磁盘清理、启动注册表编辑器、启动系统配置、启动控制面板、启动画图程序、启动计算器程序、启动DirectX诊断工具、启动服务、启动计算机管理、启动系统信息、启动更改适配器…

基于法医调查优化的BP神经网络(分类应用) - 附代码

基于法医调查优化的BP神经网络&#xff08;分类应用&#xff09; - 附代码 文章目录 基于法医调查优化的BP神经网络&#xff08;分类应用&#xff09; - 附代码1.鸢尾花iris数据介绍2.数据集整理3.法医调查优化BP神经网络3.1 BP神经网络参数设置3.2 法医调查算法应用 4.测试结果…

基于食肉植物优化的BP神经网络(分类应用) - 附代码

基于食肉植物优化的BP神经网络&#xff08;分类应用&#xff09; - 附代码 文章目录 基于食肉植物优化的BP神经网络&#xff08;分类应用&#xff09; - 附代码1.鸢尾花iris数据介绍2.数据集整理3.食肉植物优化BP神经网络3.1 BP神经网络参数设置3.2 食肉植物算法应用 4.测试结果…

《向量数据库指南》——向量数据库Milvus Cloud快速打造知识库 AI 应用

快速打造知识库 AI 应用 具备知识库的 AI Chatbot 已然是当下基于大模型技术实现及应用最多的情景,接下来,我们将以制作一个具备 Dify 产品及团队知识背景的 AI 应用为例,为大家介绍如何从零开始,用 3 步搭建一个具备企业知识库的 AI 应用。 平台注册 在本次实操演示中,我…

【RocketMQ系列五】消息示例-顺序消息延迟消息广播消息的实现

1. 前言 上一篇文章我们介绍了简单消息的实现&#xff0c;本文将主要来介绍顺序消息的实现&#xff0c;顺序消息分为局部顺序消息和全局顺序消息。 顺序消息指的是消费者在消费消息时&#xff0c;按照生产者发送消息的顺序进行消费。即先发送的先消费【FIFO】。 顺序消息分为…

凉鞋的 Godot 笔记 203. 变量的常用类型

203. 变量的常用类型 在上一篇&#xff0c;我们对变量进行了概述和简介&#xff0c;知识地图如下&#xff1a; 我们已经接触了&#xff0c;变量的字符串类型&#xff0c;以及一些功能。 在这一篇&#xff0c;我们尝试多接触一些变量的类型。 首先是整数类型。 整数类型 整…

生成指定范围内的指定个数的随机整数numpy.random.randint()

【小白从小学Python、C、Java】 【计算机等级考试500强双证书】 【Python-数据分析】 生成指定范围内的 指定个数的随机整数 numpy.random.randint() [太阳]选择题 以下哪个选项正确地描述了上述代码的功能&#xff1f; import numpy as np arr np.random.randint(1, 10, 5) p…

第一节——vue安装+前端工程化

作者&#xff1a;尤雨溪 官网&#xff1a;简介 | Vue.js 脚手架文档 创建一个项目 | Vue CLI 一、概念&#xff08;了解&#xff09; 是一套用于构建用户界面的渐进式框架。与其它大型框架不同的是&#xff0c;Vue 被设计为可以自底向上逐层应用。Vue 的核心库只关注视图层&…

凉鞋的 Unity 笔记 203. 变量的常用类型

203. 变量的常用类型 在上一篇&#xff0c;我们对变量进行了概述和简介&#xff0c;知识地图如下&#xff1a; 我们已经接触了变量的字符串类型&#xff0c;以及一些功能。 在这一篇&#xff0c;我们尝试多接触一些变量的类型。 首先是整数类型。 整数类型 整数类型一般是…