Collaboration
components/collaboration-demo.tsx
'use client';
import React from 'react';
import { YjsPlugin } from '@udecode/plate-yjs/react';
import {
Plate,
useEditorRef,
usePlateEditor,
usePluginOption,
} from '@udecode/plate/react';
import { RefreshCw } from 'lucide-react';
import { nanoid } from 'nanoid';
import { editorPlugins } from '@/components/editor/plugins/editor-plugins';
import { editorComponents } from '@/components/editor/use-create-editor';
import { useMounted } from '@/hooks/use-mounted';
import { Button } from '@/components/plate-ui/button';
import { Editor, EditorContainer } from '@/components/plate-ui/editor';
import { Input } from '@/components/plate-ui/input';
import { withPlaceholders } from '@/components/plate-ui/placeholder';
import { RemoteCursorOverlay } from '@/components/plate-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(
{
components: withPlaceholders(editorComponents),
plugins: [
...editorPlugins,
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="bg-background"
value={roomName}
onChange={handleRoomChange}
h="sm"
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="xs"
variant="outline"
className="ml-auto h-6"
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;
};