大家好,我是前端西瓜哥。
最近都在做 图形编辑器的多人协同功能 ,来分享分享自己对多人光标功能实现的心得。
如果编辑器要支持多人协同,对于 其它用户操作的感知(Awaness) 是非常重要的。
这样用户可以知道此时有谁在和你一起在操作当前文档,以及他们在做什么,以便有意识地不去同时操作同一对象,而引发不必要的冲突。
需要同步的信息
对于图形编辑器(白板工具、流程图工具等),需要同步的信息有:
-
光标位置;
-
用户信息,包括用户 id 和用户名;
-
感知 id(或者可以理解为会话 id、客户端 id)。一个用户可以登录多个客户端打开同一张图纸,所以要基于会话的唯一标识而不是用户 id 来区分光标;
-
光标颜色。
上面这些属于比较通用的信息类型。
yjs 的 awaness
因为需要实时同步每个客户端的信息,我们需要用到后端 Websocket 做服务器消息推送。
我做图形编辑器 选择了 yjs 做协同,yjs 是基于 CRDT 算法的协同库,有一套完整的数据同步并解决冲突的方案,支持各种数据结构。
里面就提供了一种 awareness(感知) 的功能,可用于实现用户光标状态同步。
初始化时,连接到 ws 服务器,然后用 provider.awareness 的 API 发送初始状态。
import * as Y from 'yjs';
const yDoc = new Y.Doc();
// 通过 ws 加入文档对应的房间
const provider = new WebsocketProvider(
'ws://localhost:8912',
'suika-demo-room',
yDoc
);
// 同步用户初始信息,key 使用 'user'
provider.awareness.setLocalStateField('user', {
id: user.id,
name: user.username,
awarenessId: this.awareness.clientID,
pos: null,
color: getRandomColor(),
});
然后是监听 awareness 变化,进行多个光标的渲染。
const onAwarenessChange = () => {
const users = Array.from(this.awareness.getStates().values())
.filter((item) => item.user)
.map((item) => item.user)
// 这里再将光标数组信息发送到组件上,把这些数据做渲染
eventEmitter.emit('usersChange', users);
};
// 监听 awareness 变化
provider.awareness.on('change', this.onAwarenessChange);
编辑器需要实现一个 cursor 位置更新事件,我们要监听光标改变,把位置同步给其它用户。
考虑到鼠标移动事件触发频率相当高,我们需要做一个节流。
// 基于节流(throttle)的方式同步信息
const onCursorPosChange = throttle((pos) => {
const activeClientCount = this.awareness.getStates().size;
// 房间只有一个人,就不同步信息了
if (activeClientCount < 2) return;
const localState = this.awareness.getLocalState()!;
// 只改位置,其它用户名和 id 之类的不用更新
this.awareness.setLocalStateField('user', {
...localState.user,
pos: { ...pos },
});
}, 80);
// 监听编辑器的光标位置改变
editor.mouseEventManager.on(
'cursorPosUpdate',
this.onCursorPosChange,
);
yjs 并不会给 awareness 同步做节流,所以我们需要自己来实现。
另外,当房间里就只有你一个人的时候,更新 pos 其实没有意义,就不发送数据给服务器,增加服务器负担了。
光标颜色
我们给不同用户提供不同颜色的光标。
实现获取随机颜色值:
const getRandomColor = () => {
const randNum = getRandom(0, colors.length - 1);
return colors[randNum];
};
const colors = [
'#0C83AC',
'#14B531',
'#FFBC42',
'#EE6352',
'#26A0B3',
'#3B9C37',
'#0794A5',
'#FFCD29',
'#FF0044',
'#9747FF',
'#FF24BD',
'#14AE5C',
];
const getRandom = (min: number, max: number) => {
if (min > max) {
[min, max] = [max, min];
}
min = Math.floor(min);
max = Math.ceil(max);
return Math.floor(Math.random() * (max - min + 1) + min);
};
颜色可以多放一些。
需要注意的是,可能多个用户的光标颜色是一样的,这个是可以接受的,因为同时活跃用户数可能超过提供的随机颜色值。
当然可以优化一下:取随机色后,再对比已经加入房间的用户的颜色,如果颜色重复,就再取一次随机色,直到值不重复或达到一定次数为止。
颜色值也有讲究,要取颜色比较深的值,否则光标会不明显。
基于颜色值,我们会用套用 SVG 模板,构建一个光标图片出来,放到和 canvas 元素相同大小的上层的 div 容器上。
下面我么来介绍如何在 div 上渲染多个光标。
渲染多个光标
我们没有选择在 canvas 上绘制光标,因为光标的位置更新是一个高频操作,绘制到 canvas 上会频繁触发 drawcall。
且这些光标的层级都是放在顶层的,所以我们额外创建了和画布 canvas 重叠的更高一层的 div 元素。
我们需要监听 canvas 宽高变化,修改 div 的宽高。
然后光标所在的容器 div 元素,需要设置下面 css 属性。
.sk-cursors-view {
/* 无法点中,直接穿透 */
pointer-events: none;
/* 截断跑到区域外的光标 */
overflow: hidden;
}
下面是关于光标 image 的绘制。
这里使用 svg 模板,传入光标颜色值得到一个 base64,设置为 img 元素的 src。顺便用个哈希表做颜色值到 base64 缓存。
const colorfulCursorMap = new Map<string, string>();
export const getColorfulCursor = (color: string) => {
if (colorfulCursorMap.has(color)) {
return colorfulCursorMap.get(color)!;
}
const svgStr = `<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="...">
<path d="..." fill="${color}"/>
...
</svg>`;
const dataUrl = `data:image/svg+xml,${svgStr
.replace(/"/g, "'")
.replace(/#/g, '%23')}`;
colorfulCursorMap.set(color, dataUrl);
return dataUrl;
};
前面监听 awareness 变化事件,会拿到最新的 user 数组,然后基于这个数组生成多个 “光标+用户名”。
这里我们需要将 pos 做 场景坐标到视口坐标的转换,设置为光标 div 的位置(这里用了 translate),然后放入之前生成的 svg 图片,然后图片右下角的位置显示一个用户名 div。
注意 user 数组需要做一层过滤,去掉 pos 为空,以及用户为当前用户的情况。
users
.filter(
(user) => user.pos && user.awarenessId !== awarenessClientId,
)
.map((user) => {
{/* 场景坐标转视口坐标 */}
const pos = toViewportPos(user.pos);
return (
<div
key={user.awarenessId}
style={{
willChange: 'transform',
// 光标位置
transform: `translate3d(${pos.x}px, ${pos.y}px, 0px)`,
}}
>
{/* 光标图片 */}
<img
className="sk-multi-cursor-image"
src={getColorfulCursor(user.color)}
/>
<div
className="sk-multi-cursor-username"
style={{
background: user.color,
}}
>
{user.name}
</div>
</div>
);
})}
光标更新时机
首先自然是其它用户移动光标更新了 user.pos 信息,然后通过 awareness 更新过来,导致 user 数组发生了更新,此时要重新计算然后渲染。
然后是画布缩放或移动的时候也要更新,因为光标是渲染在视口上的,虽然场景坐标没变,但视图矩阵发生了变化,视口的坐标就要重新算了。
其它
还要些其它的功能点和优化点。
1、右上角显示活跃用户列表,只需要把 user 列表展示一下就好了,这个其实是必须要实现的。下面是 figma 的效果。
2、光标移动时做补帧动画,实现平滑移动的效果
3、同步用户选中的图形 id 数组,用对应的颜色高亮选中。同步矩形选区信息,绘制其它用户的框选矩形。
4、光标可以整活,比如自己定义光标样式,用一些风格独特的光标。figma 之前玩过一会自定义光标营销。
结尾
就是这些,希望对你有帮助。
我是前端西瓜哥,关注我,学习更多图形编辑器知识。
相关阅读,