You can open this page in another tab or share your Room ID with others to test real-time collaboration. Each instance will have a different cursor color for easy identification.
- Share your Room ID with others to collaborate in the same document
- Limited to 10 concurrent participants per room
- Using WebRTC with public signaling servers - for demo purposes only
'use client';
import * as React from 'react';
import { YjsPlugin } from '@platejs/yjs/react';
import { RefreshCw } from 'lucide-react';
import { nanoid } from 'nanoid';
import {
Plate,
useEditorRef,
usePlateEditor,
usePluginOption,
} from 'platejs/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { EditorKit } from '@/components/editor/editor-kit';
import { useMounted } from '@/hooks/use-mounted';
import { Editor, EditorContainer } from '@/components/ui/editor';
import { RemoteCursorOverlay } from '@/components/ui/remote-cursor-overlay';
const INITIAL_VALUE = [
{
children: [{ text: 'This is the initial content loaded into the Y.Doc.' }],
type: 'p',
},
];
export default function CollaborativeEditingDemo(): React.ReactNode {
const mounted = useMounted();
const { generateNewRoom, roomName, handleRoomChange } =
useCollaborationRoom();
const { cursorColor, username } = useCollaborationUser();
const editor = usePlateEditor(
{
plugins: [
...EditorKit,
YjsPlugin.configure({
options: {
cursors: {
data: { color: cursorColor, name: username },
},
providers: [
{
options: {
name: roomName,
url: 'ws://localhost:8888',
},
type: 'hocuspocus',
},
{
options: {
maxConns: 9, // Limit to 10 total participants
roomName: roomName,
signaling: [
process.env.NODE_ENV === 'production'
? // Use public signaling server just for demo purposes
'wss://signaling.yjs.dev'
: 'ws://localhost:4444',
],
},
type: 'webrtc',
},
],
},
render: {
afterEditable: RemoteCursorOverlay,
},
}),
],
skipInitialization: true,
},
[roomName]
);
React.useEffect(() => {
if (!mounted) return;
editor.getApi(YjsPlugin).yjs.init({
id: roomName,
autoSelect: 'end',
value: INITIAL_VALUE,
});
return () => {
editor.getApi(YjsPlugin).yjs.destroy();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor, mounted]);
return (
<div className="flex flex-col">
<div className="rounded-md bg-muted p-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<div className="flex-1">
<label className="mb-1 block text-xs font-medium" htmlFor="room-id">
Room ID (share this to collaborate)
</label>
<div className="flex items-center gap-2">
<Input
id="room-id"
className="h-[28px] bg-background px-1.5 py-1"
value={roomName}
onChange={handleRoomChange}
type="text"
/>
<Button
size="icon"
variant="outline"
onClick={generateNewRoom}
title="Generate new room"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<p className="mt-2">
You can{' '}
<a
className="underline underline-offset-4 transition-colors hover:text-primary"
href={typeof window === 'undefined' ? '#' : window.location.href}
rel="noopener noreferrer"
target="_blank"
>
open this page in another tab
</a>{' '}
or share your Room ID with others to test real-time collaboration.
Each instance will have a different cursor color for easy
identification.
</p>
<div className="mt-2">
<strong>About this demo:</strong>
<ul className="mt-1 list-inside list-disc">
<li>
Share your Room ID with others to collaborate in the same document
</li>
<li>Limited to 10 concurrent participants per room</li>
<li>
Using WebRTC with public signaling servers - for demo purposes
only
</li>
</ul>
</div>
</div>
<div className="flex-1 overflow-hidden border-t">
<Plate editor={editor}>
<CollaborativeEditor cursorColor={cursorColor} username={username} />
</Plate>
</div>
</div>
);
}
function CollaborativeEditor({
cursorColor,
username,
}: {
cursorColor: string;
username: string;
}): React.ReactNode {
const editor = useEditorRef();
const providers = usePluginOption(YjsPlugin, '_providers');
const isConnected = usePluginOption(YjsPlugin, '_isConnected');
const toggleConnection = () => {
if (editor.getOptions(YjsPlugin)._isConnected) {
return editor.getApi(YjsPlugin).yjs.disconnect();
}
editor.getApi(YjsPlugin).yjs.connect();
};
return (
<>
<div className="bg-muted px-4 py-2 font-medium">
Connected as <span style={{ color: cursorColor }}>{username}</span>
<div className="mt-1 flex items-center gap-2 text-xs">
{providers.map((provider) => (
<span
key={provider.type}
className={`rounded px-2 py-0.5 ${
provider.isConnected
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{provider.type.charAt(0).toUpperCase() + provider.type.slice(1)}:{' '}
{provider.isConnected ? 'Connected' : 'Disconnected'}
</span>
))}
<Button
size="sm"
variant="outline"
className="ml-auto"
onClick={toggleConnection}
>
{isConnected ? 'Disconnect' : 'Connect'}
</Button>
</div>
</div>
<EditorContainer variant="demo">
<Editor autoFocus />
</EditorContainer>
</>
);
}
// Hook for managing room state
function useCollaborationRoom() {
const [roomName, setRoomName] = React.useState(() => {
if (typeof window === 'undefined') return '';
const storedRoomId = localStorage.getItem('demo-room-id');
if (storedRoomId) return storedRoomId;
const newRoomId = nanoid();
localStorage.setItem('demo-room-id', newRoomId);
return newRoomId;
});
const handleRoomChange = React.useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newRoomId = e.target.value;
localStorage.setItem('demo-room-id', newRoomId);
setRoomName(newRoomId);
},
[]
);
const generateNewRoom = React.useCallback(() => {
const newRoomId = nanoid();
localStorage.setItem('demo-room-id', newRoomId);
setRoomName(newRoomId);
}, []);
return {
generateNewRoom,
roomName,
handleRoomChange,
};
}
// Hook for managing user/cursor state
function useCollaborationUser() {
const [username] = React.useState(
() => `user-${Math.floor(Math.random() * 1000)}`
);
const [cursorColor] = React.useState(() => getRandomColor());
return {
cursorColor,
username,
};
}
const getRandomColor = (): string => {
const letters = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
};


核心特性
- 多提供者支持:通过 Yjs 和 slate-yjs 实现实时协作。支持多个同步提供者(如 Hocuspocus + WebRTC)同时操作共享的
Y.Doc。 - 内置提供者:开箱即用支持 Hocuspocus(服务端方案)和 WebRTC(点对点方案)。
- 自定义提供者:通过实现
UnifiedProvider接口可扩展自定义提供者(如 IndexedDB 离线存储)。 - 状态感知与光标:集成 Yjs Awareness 协议共享光标位置等临时状态,包含
RemoteCursorOverlay组件渲染远程光标。 - 可定制光标:通过
cursors配置光标外观(名称、颜色)。 - 手动生命周期:提供明确的
init和destroy方法管理 Yjs 连接。
使用指南
安装
安装核心 Yjs 插件和所需提供者包:
pnpm add @platejs/yjs
Hocuspocus 服务端方案:
pnpm add @hocuspocus/provider
WebRTC 点对点方案:
pnpm add y-webrtc
添加插件
import { YjsPlugin } from '@platejs/yjs/react';
import { createPlateEditor } from 'platejs/react';
const editor = createPlateEditor({
plugins: [
// ...其他插件
YjsPlugin,
],
// 重要:使用 Yjs 时需跳过 Plate 的默认初始化
skipInitialization: true,
});必要编辑器配置
创建编辑器时必须设置 skipInitialization: true。Yjs 负责管理初始文档状态,跳过 Plate 的默认值初始化可避免冲突。
配置 YjsPlugin
配置插件提供者和光标设置:
import { YjsPlugin } from '@platejs/yjs/react';
import { createPlateEditor } from 'platejs/react';
import { RemoteCursorOverlay } from '@/components/ui/remote-cursor-overlay';
const editor = createPlateEditor({
plugins: [
// ...其他插件
YjsPlugin.configure({
render: {
afterEditable: RemoteCursorOverlay,
},
options: {
// 配置本地用户光标外观
cursors: {
data: {
name: '用户名', // 替换为动态用户名
color: '#aabbcc', // 替换为动态用户颜色
},
},
// 配置提供者(所有提供者共享同一个 Y.Doc 和 Awareness 实例)
providers: [
// Hocuspocus 提供者示例
{
type: 'hocuspocus',
options: {
name: '我的文档ID', // 文档唯一标识
url: 'ws://localhost:8888', // Hocuspocus 服务地址
},
},
// WebRTC 提供者示例(可与 Hocuspocus 同时使用)
{
type: 'webrtc',
options: {
roomName: '我的文档ID', // 需与文档标识一致
signaling: ['ws://localhost:4444'], // 可选:信令服务器地址
},
},
],
},
}),
],
skipInitialization: true,
});render.afterEditable:指定RemoteCursorOverlay渲染远程用户光标。cursors.data:配置本地用户光标显示名称和颜色。providers:协作提供者数组(Hocuspocus、WebRTC 或自定义提供者)。
添加编辑器容器
RemoteCursorOverlay 需要定位容器包裹编辑器内容,使用 EditorContainer 或 platejs/react 的 PlateContainer:
import { Plate } from 'platejs/react';
import { EditorContainer } from '@/components/ui/editor';
return (
<Plate editor={editor}>
<EditorContainer>
<Editor />
</EditorContainer>
</Plate>
);初始化 Yjs 连接
Yjs 连接和状态需手动初始化(通常在 useEffect 中处理):
import React, { useEffect } from 'react';
import { YjsPlugin } from '@platejs/yjs/react';
import { useMounted } from '@/hooks/use-mounted'; // 或自定义挂载检查
const MyEditorComponent = ({ documentId, initialValue }) => {
const editor = usePlateEditor(/** 前文配置 **/);
const mounted = useMounted();
useEffect(() => {
if (!mounted) return;
// 初始化 Yjs 连接并设置初始状态
editor.getApi(YjsPlugin).yjs.init({
id: documentId, // Yjs 文档唯一标识
value: initialValue, // Y.Doc 为空时的初始内容
});
// 清理:组件卸载时销毁连接
return () => {
editor.getApi(YjsPlugin).yjs.destroy();
};
}, [editor, mounted]);
return (
<Plate editor={editor}>
<EditorContainer>
<Editor />
</EditorContainer>
</Plate>
);
};初始值:init 的 value 仅在后台/对等网络中文档完全空时生效。若文档已存在,将同步现有内容并忽略该值。
生命周期管理:必须调用 editor.api.yjs.init() 建立连接,并在组件卸载时调用 editor.api.yjs.destroy() 清理资源。
监控连接状态(可选)
访问提供者状态并添加事件监听:
import React from 'react';
import { YjsPlugin } from '@platejs/yjs/react';
import { usePluginOption } from 'platejs/react';
function EditorStatus() {
// 直接访问提供者状态(只读)
const providers = usePluginOption(YjsPlugin, '_providers');
const isConnected = usePluginOption(YjsPlugin, '_isConnected');
return (
<div>
{providers.map((provider) => (
<span key={provider.type}>
{provider.type}: {provider.isConnected ? '已连接' : '未连接'} ({provider.isSynced ? '已同步' : '同步中'})
</span>
))}
</div>
);
}
// 添加连接事件处理器:
YjsPlugin.configure({
options: {
// ... 其他配置
onConnect: ({ type }) => console.debug(`${type} 提供者已连接!`),
onDisconnect: ({ type }) => console.debug(`${type} 提供者已断开`),
onSyncChange: ({ type, isSynced }) => console.debug(`${type} 提供者同步状态: ${isSynced}`),
onError: ({ type, error }) => console.error(`${type} 提供者错误:`, error),
},
});提供者类型
Hocuspocus 提供者
基于 Hocuspocus 的服务端方案,需运行 Hocuspocus 服务。
type HocuspocusProviderConfig = {
type: 'hocuspocus',
options: {
name: string; // 文档标识
url: string; // WebSocket 服务地址
token?: string; // 认证令牌
}
}WebRTC 提供者
基于 y-webrtc 的点对点方案。
type WebRTCProviderConfig = {
type: 'webrtc',
options: {
roomName: string; // 协作房间名
signaling?: string[]; // 信令服务器地址
password?: string; // 房间密码
maxConns?: number; // 最大连接数
peerOpts?: object; // WebRTC 对等选项
}
}自定义提供者
通过实现 UnifiedProvider 接口创建自定义提供者:
interface UnifiedProvider {
awareness: Awareness;
document: Y.Doc;
type: string;
connect: () => void;
destroy: () => void;
disconnect: () => void;
isConnected: boolean;
isSynced: boolean;
}直接在提供者数组中使用:
const customProvider = new MyCustomProvider({ doc: ydoc, awareness });
YjsPlugin.configure({
options: {
providers: [customProvider],
},
});后端配置
Hocuspocus 服务
搭建 Hocuspocus 服务,确保提供者配置中的 url 和 name 与服务端匹配。
WebRTC 配置
信令服务器
WebRTC 需信令服务器进行节点发现。测试可使用公共服务器,生产环境建议自建:
pnpm add y-webrtc PORT=4444 node ./node_modules/y-webrtc/bin/server.js
客户端配置自定义信令:
{
type: 'webrtc',
options: {
roomName: '文档-1',
signaling: ['ws://您的信令服务器:4444'],
},
}TURN 服务器
WebRTC 连接可能因防火墙失败。生产环境建议使用 TURN 服务器或结合 Hocuspocus。
配置 TURN 服务器提升连接可靠性:
{
type: 'webrtc',
options: {
roomName: '文档-1',
signaling: ['ws://您的信令服务器:4444'],
peerOpts: {
config: {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: 'turn:您的TURN服务器:3478',
username: '用户名',
credential: '密码'
}
]
}
}
}
}安全实践
认证与授权:
- 使用 Hocuspocus 的
onAuthenticate钩子验证用户 - 后端实现文档级访问控制
- 通过
token选项传递认证令牌
传输安全:
- 生产环境使用
wss://加密通信 - 配置
turns://协议的 TURN 服务器
WebRTC 安全:
- 使用
password选项控制房间访问 - 配置安全信令服务器
安全配置示例:
YjsPlugin.configure({
options: {
providers: [
{
type: 'hocuspocus',
options: {
name: '安全文档ID',
url: 'wss://您的Hocuspocus服务',
token: '用户认证令牌',
},
},
{
type: 'webrtc',
options: {
roomName: '安全文档ID',
password: '高强度房间密码',
signaling: ['wss://您的安全信令服务'],
peerOpts: {
config: {
iceServers: [
{
urls: 'turns:您的TURN服务器:443?transport=tcp',
username: '用户',
credential: '密码'
}
]
}
}
},
},
],
},
});问题排查
连接问题
检查地址与名称:
- 确认 Hocuspocus 的
url和 WebRTC 的signaling地址正确 - 确保所有协作者的
name或roomName完全一致 - 开发环境使用
ws://,生产环境使用wss://
服务状态:
- 确认 Hocuspocus 和信令服务正常运行
- 检查服务端日志错误
- WebRTC 需测试 TURN 服务器连通性
网络问题:
- 防火墙可能阻止 WebSocket/WebRTC 流量
- 配置 TCP 443 端口的 TURN 服务器提升穿透能力
- 浏览器控制台查看提供者错误
多文档处理
独立实例:
- 每个文档创建独立的
Y.Doc实例 - 使用唯一的文档标识作为
name/roomName - 为每个编辑器传递独立的
ydoc和awareness实例
同步问题
编辑器初始化:
- 创建编辑器时始终设置
skipInitialization: true - 使用
editor.api.yjs.init({ value })设置初始内容 - 确保所有提供者使用完全相同的文档标识
内容冲突:
- 避免手动操作共享的
Y.Doc - 所有文档操作通过编辑器由 Yjs 处理
光标问题
悬浮层配置:
- 插件配置中包含
RemoteCursorOverlay - 使用定位容器(
EditorContainer或PlateContainer) - 确认本地用户的
cursors.data(名称、颜色)配置正确
相关资源
- Yjs - 协作 CRDT 框架
- slate-yjs - Slate 的 Yjs 绑定
- Hocuspocus - Yjs 后端服务
- y-webrtc - WebRTC 提供者
- RemoteCursorOverlay - 远程光标组件
- EditorContainer - 编辑器容器组件
插件
YjsPlugin
通过 Yjs 实现实时协作,支持多提供者和远程光标。
提供者配置数组或已实例化的提供者。插件会根据配置创建实例或直接使用现有实例。所有提供者共享同一个 Y.Doc 和 Awareness。每个配置对象需指定提供者 type(如 'hocuspocus'、'webrtc')及其专属 options。自定义提供者实例需符合 UnifiedProvider 接口。
远程光标配置。设为 null 显式禁用光标。未指定时,若配置了提供者则默认启用。参数传递给 withTCursors,详见 WithCursorsOptions API。包含本地用户信息的 data 和默认 true 的 autoSend。
可选共享 Y.Doc 实例。未提供时插件会内部创建。需与其他 Yjs 工具集成或管理多文档时建议自行提供。
可选共享 Awareness 实例。未提供时插件会内部创建。
任一提供者成功连接时的回调。
任一提供者断开连接时的回调。
任一提供者发生错误(如连接失败)时的回调。
任一提供者同步状态 (provider.isSynced) 变化时的回调。
API
api.yjs.init
初始化 Yjs 连接,将其绑定到编辑器,根据插件配置设置提供者,可能填充 Y.Doc 的初始内容,并连接提供者。必须在编辑器挂载后调用。
Yjs 文档的唯一标识符(如房间名、文档 ID)。未提供时使用 editor.id。确保协作者连接到同一文档状态的关键。
编辑器的初始内容。**仅当共享状态(后端/对等端)中与 id 关联的 Y.Doc 完全为空时应用。**如果文档已存在,将同步其内容并忽略此值。可以是 Plate JSON(Value)、HTML 字符串或返回/解析为 Value 的函数。如果省略或为空,且 Y.Doc 为新文档,则使用默认空段落初始化。
是否在初始化期间自动调用所有配置提供者的 provider.connect()。默认:true。如果要使用 editor.api.yjs.connect() 手动管理连接,请设置为 false。
如果设置,在初始化和同步后自动聚焦编辑器并将光标放置在文档的 'start' 或 'end' 位置。
初始化后设置选择的具体 Plate Location,覆盖 autoSelect。
api.yjs.destroy
断开所有提供者连接,清理 Yjs 绑定(将编辑器从 Y.Doc 分离),并销毁 awareness 实例。必须在编辑器组件卸载时调用以防止内存泄漏和过时连接。
api.yjs.connect
手动连接到提供者。在 init 期间使用 autoConnect: false 时很有用。
api.yjs.disconnect
手动断开与提供者的连接。