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官方网站下载适合…...
Qt/C++开发监控GB28181系统/取流协议/同时支持udp/tcp被动/tcp主动
一、前言说明 在2011版本的gb28181协议中,拉取视频流只要求udp方式,从2016开始要求新增支持tcp被动和tcp主动两种方式,udp理论上会丢包的,所以实际使用过程可能会出现画面花屏的情况,而tcp肯定不丢包,起码…...
电脑插入多块移动硬盘后经常出现卡顿和蓝屏
当电脑在插入多块移动硬盘后频繁出现卡顿和蓝屏问题时,可能涉及硬件资源冲突、驱动兼容性、供电不足或系统设置等多方面原因。以下是逐步排查和解决方案: 1. 检查电源供电问题 问题原因:多块移动硬盘同时运行可能导致USB接口供电不足&#x…...
JUC笔记(上)-复习 涉及死锁 volatile synchronized CAS 原子操作
一、上下文切换 即使单核CPU也可以进行多线程执行代码,CPU会给每个线程分配CPU时间片来实现这个机制。时间片非常短,所以CPU会不断地切换线程执行,从而让我们感觉多个线程是同时执行的。时间片一般是十几毫秒(ms)。通过时间片分配算法执行。…...
UR 协作机器人「三剑客」:精密轻量担当(UR7e)、全能协作主力(UR12e)、重型任务专家(UR15)
UR协作机器人正以其卓越性能在现代制造业自动化中扮演重要角色。UR7e、UR12e和UR15通过创新技术和精准设计满足了不同行业的多样化需求。其中,UR15以其速度、精度及人工智能准备能力成为自动化领域的重要突破。UR7e和UR12e则在负载规格和市场定位上不断优化…...
【C++从零实现Json-Rpc框架】第六弹 —— 服务端模块划分
一、项目背景回顾 前五弹完成了Json-Rpc协议解析、请求处理、客户端调用等基础模块搭建。 本弹重点聚焦于服务端的模块划分与架构设计,提升代码结构的可维护性与扩展性。 二、服务端模块设计目标 高内聚低耦合:各模块职责清晰,便于独立开发…...
【数据分析】R版IntelliGenes用于生物标志物发现的可解释机器学习
禁止商业或二改转载,仅供自学使用,侵权必究,如需截取部分内容请后台联系作者! 文章目录 介绍流程步骤1. 输入数据2. 特征选择3. 模型训练4. I-Genes 评分计算5. 输出结果 IntelliGenesR 安装包1. 特征选择2. 模型训练和评估3. I-Genes 评分计…...
安卓基础(aar)
重新设置java21的环境,临时设置 $env:JAVA_HOME "D:\Android Studio\jbr" 查看当前环境变量 JAVA_HOME 的值 echo $env:JAVA_HOME 构建ARR文件 ./gradlew :private-lib:assembleRelease 目录是这样的: MyApp/ ├── app/ …...
Python 包管理器 uv 介绍
Python 包管理器 uv 全面介绍 uv 是由 Astral(热门工具 Ruff 的开发者)推出的下一代高性能 Python 包管理器和构建工具,用 Rust 编写。它旨在解决传统工具(如 pip、virtualenv、pip-tools)的性能瓶颈,同时…...
【Java学习笔记】BigInteger 和 BigDecimal 类
BigInteger 和 BigDecimal 类 二者共有的常见方法 方法功能add加subtract减multiply乘divide除 注意点:传参类型必须是类对象 一、BigInteger 1. 作用:适合保存比较大的整型数 2. 使用说明 创建BigInteger对象 传入字符串 3. 代码示例 import j…...
R 语言科研绘图第 55 期 --- 网络图-聚类
在发表科研论文的过程中,科研绘图是必不可少的,一张好看的图形会是文章很大的加分项。 为了便于使用,本系列文章介绍的所有绘图都已收录到了 sciRplot 项目中,获取方式: R 语言科研绘图模板 --- sciRplothttps://mp.…...
