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 服务端的功能即可
- 可以使用 yjs 推荐的 y-websocket 的 nodejs 服务
HOST=localhost PORT=8080 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/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 实现实时在线多人协作的绘画功能 支持多客户端实时共享编辑自动同步,离线支持自动合并,自动冲突处理 1. 客户端代码(基于Vue3) 实现绘画功能 <template><div style"{width: 100vw; height: 100vh; over…...
虹科分享 | 赋能物流机器人:CANopen通信如何发挥重要作用?
现代物流领域迅速融入了技术进步,特别是随着自主机器人的兴起,这一趋势越发明显。确保这些机器人在复杂的仓库环境中精确运行的一个关键方面是CANopen通信协议。该协议集成了各种组件(电机、传感器、摄像头和先进的电池系统)&…...
南丁格尔玫瑰图
目录 由来 效果图 echarts官网找相似图 将南丁格尔玫瑰图引进html页面中 引入echarts 准备容器 初始化echarts实例对象 指定配置项和数据(官网给的option) 将配置项给echarts 自定义南格丁尔玫瑰图 修改颜色 修改玫瑰图大小 修改图的模式为半…...
vue 大文件切片下载
前提是你上传的时候也是切片上传,下载的时候后端给你返回的是一个文件id的数组,如果是你就可以用下面的方法 // 循环下载文件 // id是每个文件的id type 是一个类型,我传入是应为给不同的组件赋值getFile(id, type) {// 通过wen文件id去获取…...
2023年“绿盟杯”四川省大学生信息安全技术大赛
pyfile 先check源码,没什么发现,接着进行目录扫描,扫到路径 /download 下载备份文件得到 www.zip,解压得到app.py 大致审一下代码: 在read目录下给file传参进行请求,如果这个东西存在就会读取出来 这里…...
YOLOv8改进实战 | 更换主干网络Backbone(二)之轻量化模型GhostnetV2
前言 轻量化网络设计是一种针对移动设备等资源受限环境的深度学习模型设计方法。下面是一些常见的轻量化网络设计方法: 网络剪枝:移除神经网络中冗余的连接和参数,以达到模型压缩和加速的目的。分组卷积:将卷积操作分解为若干个较小的卷积操作,并将它们分别作用于输入的不…...
【C++代码】二叉搜索树的最近公共祖先,二叉搜索树中的插入操作,删除二叉搜索树中的节点--代码随想录
题目:二叉搜索树的最近公共祖先 给定一个二叉搜索树, 找到该树中两个指定节点的最近公共祖先。最近公共祖先的定义为:“对于有根树 T 的两个结点 p、q,最近公共祖先表示为一个结点 x,满足 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中,您可以使用TUniDeviceInfo类来读取设备的一些基本信息,例如设备的操作系统版本、设备名称和分辨率等。但是,TUniDeviceInfo类并不提供设备的特征码信息。 如果您想要获取设备的特征码信息,您可以使用JavaScrip…...
Github Actions实现Spring Boot自动化部署(第二弹)
Github Actions实现Spring Boot自动化部署(第二弹) 前言 今天就来讲述一下如何使用GitHub结合Actions实现Spring Boot程序从提交代码到打包、容器化、部署全过程自动化。首先咱们得现有一个能够在本地运行的Spring Boot程序,并且在Githu…...
【Python机器学习】sklearn.datasets分类任务数据集
如何选择合适的数据集进行机器学习的分类任务? 选择合适的数据集是进行任何机器学习项目的第一步,特别是分类任务。数据集是机器学习任务成功的基础。没有数据,最先进的算法也无从谈起。 本文将专注于sklearn.datasets模块中用于分类任务的数据集。这些数据集覆盖了各种场…...
华为OD 数组去重和排序(100分)【java】A卷+B卷
华为OD统一考试A卷B卷 新题库说明 你收到的链接上面会标注A卷还是B卷。目前大部分收到的都是B卷。 B卷对应20022部分考题以及新出的题目,A卷对应的是新出的题目。 我将持续更新最新题目 获取更多免费题目可前往夸克网盘下载,请点击以下链接进入ÿ…...
黑客技术(网络安全)学习
1.网络安全是什么 网络安全可以基于攻击和防御视角来分类,我们经常听到的 “红队”、“渗透测试” 等就是研究攻击技术,而“蓝队”、“安全运营”、“安全运维”则研究防御技术。 2.网络安全市场 一、是市场需求量高; 二、则是发展相对成熟…...
【算法|动态规划No.28】leetcode1312. 让字符串成为回文串的最少插入次数
个人主页:兜里有颗棉花糖 欢迎 点赞👍 收藏✨ 留言✉ 加关注💓本文由 兜里有颗棉花糖 原创 收录于专栏【手撕算法系列专栏】【LeetCode】 🍔本专栏旨在提高自己算法能力的同时,记录一下自己的学习过程,希望…...
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,返回这两个整数的和。 示例 1: 输入:num1 12, num2 5 输出:17 解释:num1 是 12,num2 是 5 ,它们的和是 12 5 17 ,因此返回 17 。 示例 …...
【Linux升级之路】8_Linux多线程
目录 一、【Linux初阶】多线程1 | 页表的索引作用,线程基础(优缺点、异常、用途),线程VS进程,线程控制,C多线程引入二、【Linux初阶】多线程2 | 分离线程,线程库,线程互斥࿰…...
FFT64点傅里叶变换verilog蝶形运算,代码和视频
名称:FFT64点verilog傅里叶变换 软件:Quartus 语言:Verilog 代码功能: 使用verilog代码实现64点FFT变换,使用蝶形运算实现傅里叶变换 演示视频:http://www.hdlcode.com/index.php?mhome&cView&…...
学习JS闭包
作用域 作用域分为:全局作用域和函数作用域。链式作用域:子对象会一级一级往上查找父对象的变量。 什么是闭包? 闭包可以理解为定义在函数内部的函数,是由一个函数以及与其相关的引用环境组合而成的实体。可以在函数内部访问外部函数的变量&a…...
在Mac上安装配置svn
版本控制系统对于程序员来说是至关重要的工具,而Subversion(简称svn)就是一种流行的版本控制系统。本文将指导你在Mac上安装并配置svn,让你更好地管理代码版本。 安装svn 首先,我们需要从Subversion官方网站下载适合…...
相机Camera日志实例分析之二:相机Camx【专业模式开启直方图拍照】单帧流程日志详解
【关注我,后续持续新增专题博文,谢谢!!!】 上一篇我们讲了: 这一篇我们开始讲: 目录 一、场景操作步骤 二、日志基础关键字分级如下 三、场景日志如下: 一、场景操作步骤 操作步…...
佰力博科技与您探讨热释电测量的几种方法
热释电的测量主要涉及热释电系数的测定,这是表征热释电材料性能的重要参数。热释电系数的测量方法主要包括静态法、动态法和积分电荷法。其中,积分电荷法最为常用,其原理是通过测量在电容器上积累的热释电电荷,从而确定热释电系数…...
三分算法与DeepSeek辅助证明是单峰函数
前置 单峰函数有唯一的最大值,最大值左侧的数值严格单调递增,最大值右侧的数值严格单调递减。 单谷函数有唯一的最小值,最小值左侧的数值严格单调递减,最小值右侧的数值严格单调递增。 三分的本质 三分和二分一样都是通过不断缩…...
在鸿蒙HarmonyOS 5中使用DevEco Studio实现企业微信功能
1. 开发环境准备 安装DevEco Studio 3.1: 从华为开发者官网下载最新版DevEco Studio安装HarmonyOS 5.0 SDK 项目配置: // module.json5 {"module": {"requestPermissions": [{"name": "ohos.permis…...
Ubuntu系统复制(U盘-电脑硬盘)
所需环境 电脑自带硬盘:1块 (1T) U盘1:Ubuntu系统引导盘(用于“U盘2”复制到“电脑自带硬盘”) U盘2:Ubuntu系统盘(1T,用于被复制) !!!建议“电脑…...
DiscuzX3.5发帖json api
参考文章:PHP实现独立Discuz站外发帖(直连操作数据库)_discuz 发帖api-CSDN博客 简单改造了一下,适配我自己的需求 有一个站点存在多个采集站,我想通过主站拿标题,采集站拿内容 使用到的sql如下 CREATE TABLE pre_forum_post_…...
面试高频问题
文章目录 🚀 消息队列核心技术揭秘:从入门到秒杀面试官1️⃣ Kafka为何能"吞云吐雾"?性能背后的秘密1.1 顺序写入与零拷贝:性能的双引擎1.2 分区并行:数据的"八车道高速公路"1.3 页缓存与批量处理…...
sshd代码修改banner
sshd服务连接之后会收到字符串: SSH-2.0-OpenSSH_9.5 容易被hacker识别此服务为sshd服务。 是否可以通过修改此banner达到让人无法识别此服务的目的呢? 不能。因为这是写的SSH的协议中的。 也就是协议规定了banner必须这么写。 SSH- 开头,…...
TJCTF 2025
还以为是天津的。这个比较容易,虽然绕了点弯,可还是把CP AK了,不过我会的别人也会,还是没啥名次。记录一下吧。 Crypto bacon-bits with open(flag.txt) as f: flag f.read().strip() with open(text.txt) as t: text t.read…...
GeoServer发布PostgreSQL图层后WFS查询无主键字段
在使用 GeoServer(版本 2.22.2) 发布 PostgreSQL(PostGIS)中的表为地图服务时,常常会遇到一个小问题: WFS 查询中,主键字段(如 id)莫名其妙地消失了! 即使你在…...
