当前位置: 首页 > news >正文

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!// 清空 Canvascontext.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 mainimport ("net/http""github.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');}});
});

相关文章:

yjs demo: 多人在线协作画板

基于 yjs 实现实时在线多人协作的绘画功能 支持多客户端实时共享编辑自动同步&#xff0c;离线支持自动合并&#xff0c;自动冲突处理 1. 客户端代码&#xff08;基于Vue3&#xff09; 实现绘画功能 <template><div style"{width: 100vw; height: 100vh; over…...

虹科分享 | 赋能物流机器人:CANopen通信如何发挥重要作用?

现代物流领域迅速融入了技术进步&#xff0c;特别是随着自主机器人的兴起&#xff0c;这一趋势越发明显。确保这些机器人在复杂的仓库环境中精确运行的一个关键方面是CANopen通信协议。该协议集成了各种组件&#xff08;电机、传感器、摄像头和先进的电池系统&#xff09;&…...

南丁格尔玫瑰图

目录 由来 效果图 echarts官网找相似图 将南丁格尔玫瑰图引进html页面中 引入echarts 准备容器 初始化echarts实例对象 指定配置项和数据&#xff08;官网给的option&#xff09; 将配置项给echarts 自定义南格丁尔玫瑰图 修改颜色 修改玫瑰图大小 修改图的模式为半…...

vue 大文件切片下载

前提是你上传的时候也是切片上传&#xff0c;下载的时候后端给你返回的是一个文件id的数组&#xff0c;如果是你就可以用下面的方法 // 循环下载文件 // id是每个文件的id type 是一个类型&#xff0c;我传入是应为给不同的组件赋值getFile(id, type) {// 通过wen文件id去获取…...

2023年“绿盟杯”四川省大学生信息安全技术大赛

pyfile 先check源码&#xff0c;没什么发现&#xff0c;接着进行目录扫描&#xff0c;扫到路径 /download 下载备份文件得到 www.zip&#xff0c;解压得到app.py 大致审一下代码&#xff1a; 在read目录下给file传参进行请求&#xff0c;如果这个东西存在就会读取出来 这里…...

YOLOv8改进实战 | 更换主干网络Backbone(二)之轻量化模型GhostnetV2

前言 轻量化网络设计是一种针对移动设备等资源受限环境的深度学习模型设计方法。下面是一些常见的轻量化网络设计方法: 网络剪枝:移除神经网络中冗余的连接和参数,以达到模型压缩和加速的目的。分组卷积:将卷积操作分解为若干个较小的卷积操作,并将它们分别作用于输入的不…...

【C++代码】二叉搜索树的最近公共祖先,二叉搜索树中的插入操作,删除二叉搜索树中的节点--代码随想录

题目&#xff1a;二叉搜索树的最近公共祖先 给定一个二叉搜索树, 找到该树中两个指定节点的最近公共祖先。最近公共祖先的定义为&#xff1a;“对于有根树 T 的两个结点 p、q&#xff0c;最近公共祖先表示为一个结点 x&#xff0c;满足 x 是 p、q 的祖先且 x 的深度尽可能大&a…...

【ArcGIS微课1000例】0075:将AutoCAD(Dwg、Dxf)文件转换为shp、KML(kml、kmz)文件

文章目录 1. 加载DWG2. 导出为shp3. 投影变换4. 转为kml1. 加载DWG 打开ArcMap,点击添加符号: 选择地形图dwg数据,全选图层,也可以选择需要的图层。 提示位置的空间参考,点击确定即可。 加载效果。 2. 导出为shp 接下来我们演示将面状数据转为shp,选择Polygon图层,右键…...

Unigui中获取手机特征码

在Delphi Unigui中&#xff0c;您可以使用TUniDeviceInfo类来读取设备的一些基本信息&#xff0c;例如设备的操作系统版本、设备名称和分辨率等。但是&#xff0c;TUniDeviceInfo类并不提供设备的特征码信息。 如果您想要获取设备的特征码信息&#xff0c;您可以使用JavaScrip…...

Github Actions实现Spring Boot自动化部署(第二弹)

Github Actions实现Spring Boot自动化部署&#xff08;第二弹&#xff09; 前言 ​ 今天就来讲述一下如何使用GitHub结合Actions实现Spring Boot程序从提交代码到打包、容器化、部署全过程自动化。首先咱们得现有一个能够在本地运行的Spring Boot程序&#xff0c;并且在Githu…...

【Python机器学习】sklearn.datasets分类任务数据集

如何选择合适的数据集进行机器学习的分类任务? 选择合适的数据集是进行任何机器学习项目的第一步,特别是分类任务。数据集是机器学习任务成功的基础。没有数据,最先进的算法也无从谈起。 本文将专注于sklearn.datasets模块中用于分类任务的数据集。这些数据集覆盖了各种场…...

华为OD 数组去重和排序(100分)【java】A卷+B卷

华为OD统一考试A卷B卷 新题库说明 你收到的链接上面会标注A卷还是B卷。目前大部分收到的都是B卷。 B卷对应20022部分考题以及新出的题目&#xff0c;A卷对应的是新出的题目。 我将持续更新最新题目 获取更多免费题目可前往夸克网盘下载&#xff0c;请点击以下链接进入&#xff…...

黑客技术(网络安全)学习

1.网络安全是什么 网络安全可以基于攻击和防御视角来分类&#xff0c;我们经常听到的 “红队”、“渗透测试” 等就是研究攻击技术&#xff0c;而“蓝队”、“安全运营”、“安全运维”则研究防御技术。 2.网络安全市场 一、是市场需求量高&#xff1b; 二、则是发展相对成熟…...

【算法|动态规划No.28】leetcode1312. 让字符串成为回文串的最少插入次数

个人主页&#xff1a;兜里有颗棉花糖 欢迎 点赞&#x1f44d; 收藏✨ 留言✉ 加关注&#x1f493;本文由 兜里有颗棉花糖 原创 收录于专栏【手撕算法系列专栏】【LeetCode】 &#x1f354;本专栏旨在提高自己算法能力的同时&#xff0c;记录一下自己的学习过程&#xff0c;希望…...

AWS SAP-C02教程10-其它服务

接下来介绍的内容是一些SAP-C02考试会涉及到的,但是目前无法很好将其归类,暂且放在其它服务中 目录 1 AWS WorkSpaces2 AWS APP Stream 2.02.1 WorkSpaces vs APP Stream 2.03 AWS Device Farm4 AWS AppSync5 AWS Outposts6 AWS WaveLength7 AWS Local Zones8 AWS Cloud Map…...

C语言 力扣习题 10.19日 day1

1.两整数相加 给你两个整数 num1 和 num2&#xff0c;返回这两个整数的和。 示例 1&#xff1a; 输入&#xff1a;num1 12, num2 5 输出&#xff1a;17 解释&#xff1a;num1 是 12&#xff0c;num2 是 5 &#xff0c;它们的和是 12 5 17 &#xff0c;因此返回 17 。 示例 …...

【Linux升级之路】8_Linux多线程

目录 一、【Linux初阶】多线程1 | 页表的索引作用&#xff0c;线程基础&#xff08;优缺点、异常、用途&#xff09;&#xff0c;线程VS进程&#xff0c;线程控制&#xff0c;C多线程引入二、【Linux初阶】多线程2 | 分离线程&#xff0c;线程库&#xff0c;线程互斥&#xff0…...

FFT64点傅里叶变换verilog蝶形运算,代码和视频

名称&#xff1a;FFT64点verilog傅里叶变换 软件&#xff1a;Quartus 语言&#xff1a;Verilog 代码功能&#xff1a; 使用verilog代码实现64点FFT变换&#xff0c;使用蝶形运算实现傅里叶变换 演示视频&#xff1a;http://www.hdlcode.com/index.php?mhome&cView&…...

学习JS闭包

作用域 作用域分为&#xff1a;全局作用域和函数作用域。链式作用域&#xff1a;子对象会一级一级往上查找父对象的变量。 什么是闭包&#xff1f; 闭包可以理解为定义在函数内部的函数,是由一个函数以及与其相关的引用环境组合而成的实体。可以在函数内部访问外部函数的变量&a…...

在Mac上安装配置svn

版本控制系统对于程序员来说是至关重要的工具&#xff0c;而Subversion&#xff08;简称svn&#xff09;就是一种流行的版本控制系统。本文将指导你在Mac上安装并配置svn&#xff0c;让你更好地管理代码版本。 安装svn 首先&#xff0c;我们需要从Subversion官方网站下载适合…...

Qwen3-4B-Thinking部署教程:支持WebSocket长连接的实时流式响应

Qwen3-4B-Thinking部署教程&#xff1a;支持WebSocket长连接的实时流式响应 1. 模型简介 Qwen3-4B-Thinking-2507-Gemini-2.5-Flash-Distill是一个基于vLLM框架部署的文本生成模型&#xff0c;特别优化了WebSocket长连接支持&#xff0c;能够提供实时流式响应体验。该模型在约…...

单入射方向光波导耦合光栅的优化

摘要 将光耦合到光波导在现代光学的各种应用中具有重要意义。在VirtualLab Fusion中&#xff0c;使用傅里叶模态法(FMM&#xff0c;也称为RCWA)和参数优化工具&#xff0c;可以优化真实的光栅几何形状&#xff0c;以实现特定衍射级次的最佳耦合效率。本例展示了针对特定入射方…...

AI正重构你的工作!这20个职业短期内难被替代,普通人如何提前布局?

近期多份权威报告指出&#xff0c;AI将重构而非替代人类工作。AI将冲击标准化内容生产、规则化数据分析和流程化客服支持等重复性岗位&#xff0c;同时催生人机协同管理、情感与关系型工作等新需求。AI难以替代共情、道德判断等能力突出的职业&#xff0c;如心理治疗师、法官、…...

直播设备ping值延时监测工具:功能详解与使用指南

对于直播从业者、网络运维人员来说&#xff0c;实时监测网络状态是个重要需求。本文介绍一款专门用于监测网络延时的工具&#xff0c;包含核心功能解析和参数设置建议。工具能做什么一句话总结&#xff1a;同时监测多台网络设备的延时情况&#xff0c;当延时超过阈值时报警&…...

STM32F103C8T6 GPIO八种模式到底怎么选?从按键到I2C,新手避坑指南

STM32F103C8T6 GPIO八种模式实战指南&#xff1a;从按键到I2C的智能选择 第一次接触STM32的GPIO配置时&#xff0c;面对八种工作模式的选择&#xff0c;我曾在实验室熬到凌晨三点——按键死活检测不到信号&#xff0c;I2C设备频繁通信失败。后来才发现&#xff0c;问题都出在模…...

如何检测失效的SQL视图_检查依赖对象的完整性

...

从阻塞IO到虚拟线程异步编排:一个实时风控网关的毫秒级响应改造,3周上线、0宕机、TP99下降68ms

第一章&#xff1a;从阻塞IO到虚拟线程异步编排&#xff1a;一个实时风控网关的毫秒级响应改造&#xff0c;3周上线、0宕机、TP99下降68ms某支付平台风控网关原基于 Spring Boot 2.7 Tomcat 阻塞模型构建&#xff0c;日均处理 4200 万次规则校验请求&#xff0c;平均响应延迟 …...

django-cacheops实战案例:构建高性能电商系统的缓存架构设计

django-cacheops实战案例&#xff1a;构建高性能电商系统的缓存架构设计 【免费下载链接】django-cacheops A slick ORM cache with automatic granular event-driven invalidation. 项目地址: https://gitcode.com/gh_mirrors/dj/django-cacheops 在当今电商领域&#…...

XUnity.AutoTranslator终极指南:如何实现Unity游戏实时翻译的完整解决方案

XUnity.AutoTranslator终极指南&#xff1a;如何实现Unity游戏实时翻译的完整解决方案 【免费下载链接】XUnity.AutoTranslator 项目地址: https://gitcode.com/gh_mirrors/xu/XUnity.AutoTranslator 想要玩外文Unity游戏却苦于语言障碍&#xff1f;XUnity.AutoTransla…...

Hutool TreeUtil 教程

一、简介Hutool 的 TreeUtil 是一个树结构工具类&#xff0c;用于处理节点数据构建树形结构&#xff08;如菜单树、组织树、地区树等&#xff09;&#xff0c;支持无限级分类。二、核心概念2.1 关键类TreeNode: 树节点配置类&#xff0c;定义节点属性&#xff08;id、parentId、…...