feat(examples/react-rich-collab): Added collaboration focused example (#6043)

This commit is contained in:
Vlad Fedosov
2024-05-10 14:17:08 -04:00
committed by GitHub
parent 555efea152
commit 40f6699633
33 changed files with 6139 additions and 34 deletions

1
examples/react-rich-collab/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/yjs-wss-db

View File

@ -0,0 +1,7 @@
# Basic Vanilla JS example
Here we have simplest Lexical collaboration mode setup. Use it as a starting point for your own projects as well as platform for bug reporting!
**Run it locally:** `npm i && npm run dev:local`
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/facebook/lexical/tree/fix/collab_example/examples/react-rich-collab?file=src/main.ts)

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React.js Collaborative Lexical Example</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React.js Collaborative Lexical Example</title>
</head>
<body>
<h1 align="center" style="margin: 0; font-size: 1.3em">
React.js Collaborative Lexical Example (see iframes below)
</h1>
<iframe
width="50%"
name="left"
src="/app"
style="
border: 2px dashed black;
width: calc(50% - 4px);
position: fixed;
top: 40px;
left: 0;
height: calc(100% - 44px);
"></iframe>
<iframe
width="50%"
name="right"
src="/app"
style="
border: 2px dashed black;
width: calc(50% - 4px);
position: fixed;
top: 40px;
left: calc(50% + 1px);
height: calc(100% - 44px);
"></iframe>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,33 @@
{
"name": "@lexical/react-rich-example",
"private": true,
"version": "0.14.5",
"type": "module",
"scripts": {
"dev": "vite",
"dev:local": "cross-env NODE_ENV=development concurrently \"npm:server:ws\" \"npm:server:webrtc\" \"vite\"",
"build": "tsc && vite build",
"preview": "vite preview",
"server:ws": "cross-env HOST=localhost PORT=1234 YPERSISTENCE=./yjs-wss-db npx y-websocket",
"server:webrtc": "cross-env HOST=localhost PORT=1235 npx y-webrtc"
},
"dependencies": {
"@lexical/react": "0.14.5",
"@lexical/yjs": "^0.14.5",
"lexical": "0.14.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"y-webrtc": "^10.3.0",
"y-websocket": "^2.0.2",
"yjs": "^13.6.15"
},
"devDependencies": {
"@types/react": "^18.2.59",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",
"concurrently": "^8.2.2",
"cross-env": "^7.0.3",
"typescript": "^5.2.2",
"vite": "^5.1.4"
}
}

View File

@ -0,0 +1,5 @@
Bootstrap Icons
https://icons.getbootstrap.com
Licensed under MIT license
https://github.com/twbs/icons/blob/main/LICENSE.md

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-clockwise" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
</svg>

After

Width:  |  Height:  |  Size: 352 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-counterclockwise" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 1-4.546 2.914.5.5 0 0 0-.908-.417A6 6 0 1 0 8 2v1z"/>
<path d="M8 4.466V.534a.25.25 0 0 0-.41-.192L5.23 2.308a.25.25 0 0 0 0 .384l2.36 1.966A.25.25 0 0 0 8 4.466z"/>
</svg>

After

Width:  |  Height:  |  Size: 359 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-journal-text" viewBox="0 0 16 16">
<path d="M5 10.5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5zm0-2a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5zm0-2a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5zm0-2a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5z"/>
<path d="M3 0h10a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-1h1v1a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v1H1V2a2 2 0 0 1 2-2z"/>
<path d="M1 5v-.5a.5.5 0 0 1 1 0V5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1H1zm0 3v-.5a.5.5 0 0 1 1 0V8h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1H1zm0 3v-.5a.5.5 0 0 1 1 0v.5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1H1z"/>
</svg>

After

Width:  |  Height:  |  Size: 759 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-justify" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M2 12.5a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm0-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm0-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm0-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 414 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-text-center" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4 12.5a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5zm-2-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm2-3a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5zm-2-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 416 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-text-left" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M2 12.5a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5zm0-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm0-3a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5zm0-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 412 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-text-paragraph" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M2 12.5a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5zm0-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm0-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm4-3a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 417 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-text-right" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M6 12.5a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5zm-4-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm4-3a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5zm-4-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 415 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-type-bold" viewBox="0 0 16 16">
<path d="M8.21 13c2.106 0 3.412-1.087 3.412-2.823 0-1.306-.984-2.283-2.324-2.386v-.055a2.176 2.176 0 0 0 1.852-2.14c0-1.51-1.162-2.46-3.014-2.46H3.843V13H8.21zM5.908 4.674h1.696c.963 0 1.517.451 1.517 1.244 0 .834-.629 1.32-1.73 1.32H5.908V4.673zm0 6.788V8.598h1.73c1.217 0 1.88.492 1.88 1.415 0 .943-.643 1.449-1.832 1.449H5.907z"/>
</svg>

After

Width:  |  Height:  |  Size: 470 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-type-italic" viewBox="0 0 16 16">
<path d="M7.991 11.674 9.53 4.455c.123-.595.246-.71 1.347-.807l.11-.52H7.211l-.11.52c1.06.096 1.128.212 1.005.807L6.57 11.674c-.123.595-.246.71-1.346.806l-.11.52h3.774l.11-.52c-1.06-.095-1.129-.211-1.006-.806z"/>
</svg>

After

Width:  |  Height:  |  Size: 351 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-type-strikethrough" viewBox="0 0 16 16">
<path d="M6.333 5.686c0 .31.083.581.27.814H5.166a2.776 2.776 0 0 1-.099-.76c0-1.627 1.436-2.768 3.48-2.768 1.969 0 3.39 1.175 3.445 2.85h-1.23c-.11-1.08-.964-1.743-2.25-1.743-1.23 0-2.18.602-2.18 1.607zm2.194 7.478c-2.153 0-3.589-1.107-3.705-2.81h1.23c.144 1.06 1.129 1.703 2.544 1.703 1.34 0 2.31-.705 2.31-1.675 0-.827-.547-1.374-1.914-1.675L8.046 8.5H1v-1h14v1h-3.504c.468.437.675.994.675 1.697 0 1.826-1.436 2.967-3.644 2.967z"/>
</svg>

After

Width:  |  Height:  |  Size: 579 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-type-underline" viewBox="0 0 16 16">
<path d="M5.313 3.136h-1.23V9.54c0 2.105 1.47 3.623 3.917 3.623s3.917-1.518 3.917-3.623V3.136h-1.23v6.323c0 1.49-.978 2.57-2.687 2.57-1.709 0-2.687-1.08-2.687-2.57V3.136zM12.5 15h-9v-1h9v1z"/>
</svg>

After

Width:  |  Height:  |  Size: 334 B

View File

@ -0,0 +1,170 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import type {Provider} from '@lexical/yjs';
import {CollaborationPlugin} from '@lexical/react/LexicalCollaborationPlugin';
import {LexicalComposer} from '@lexical/react/LexicalComposer';
import {Fragment, useCallback, useEffect, useRef, useState} from 'react';
import * as Y from 'yjs';
import Editor from './Editor';
import ExampleTheme from './ExampleTheme';
import {getRandomUserProfile, UserProfile} from './getRandomUserProfile';
import {createWebRTCProvider, createWebsocketProvider} from './providers';
interface ActiveUserProfile extends UserProfile {
userId: number;
}
const editorConfig = {
// NOTE: This is critical for collaboration plugin to set editor state to null. It
// would indicate that the editor should not try to set any default state
// (not even empty one), and let collaboration plugin do it instead
editorState: null,
namespace: 'React.js Collab Demo',
nodes: [],
// Handling of errors during update
onError(error: Error) {
throw error;
},
// The editor theme
theme: ExampleTheme,
};
export default function App() {
const providerName =
new URLSearchParams(window.location.search).get('provider') ?? 'webrtc';
const [userProfile, setUserProfile] = useState(() => getRandomUserProfile());
const containerRef = useRef<HTMLDivElement | null>(null);
const [yjsProvider, setYjsProvider] = useState<null | Provider>(null);
const [connected, setConnected] = useState(false);
const [activeUsers, setActiveUsers] = useState<ActiveUserProfile[]>([]);
const handleAwarenessUpdate = useCallback(() => {
const awareness = yjsProvider!.awareness!;
setActiveUsers(
Array.from(awareness.getStates().entries()).map(
([userId, {color, name}]) => ({
color,
name,
userId,
}),
),
);
}, [yjsProvider]);
const handleConnectionToggle = () => {
if (yjsProvider == null) {
return;
}
if (connected) {
yjsProvider.disconnect();
} else {
yjsProvider.connect();
}
};
useEffect(() => {
if (yjsProvider == null) {
return;
}
yjsProvider.awareness.on('update', handleAwarenessUpdate);
return () => yjsProvider.awareness.off('update', handleAwarenessUpdate);
}, [yjsProvider, handleAwarenessUpdate]);
const providerFactory = useCallback(
(id: string, yjsDocMap: Map<string, Y.Doc>) => {
const provider =
providerName === 'webrtc'
? createWebRTCProvider(id, yjsDocMap)
: createWebsocketProvider(id, yjsDocMap);
provider.on('status', (event) => {
setConnected(
// Websocket provider
event.status === 'connected' ||
// WebRTC provider has different approact to status reporting
('connected' in event && event.connected === true),
);
});
// This is a hack to get reference to provider with standard CollaborationPlugin.
// To be fixed in future versions of Lexical.
setTimeout(() => setYjsProvider(provider), 0);
return provider;
},
[providerName],
);
return (
<div ref={containerRef}>
<p>
<b>Used provider:</b>{' '}
{providerName === 'webrtc'
? 'WebRTC (within browser communication via BroadcastChannel fallback, unless run locally)'
: 'Websockets (cross-browser communication)'}
<br />
{window.location.hostname === 'localhost' ? (
providerName === 'webrtc' ? (
<a href="/app?provider=wss">Enable WSS</a>
) : (
<a href="/app">Enable WebRTC</a>
)
) : null}{' '}
{/* WebRTC provider doesn't implement disconnect correctly */}
{providerName !== 'webrtc' ? (
<button onClick={handleConnectionToggle}>
{connected ? 'Disconnect' : 'Connect'}
</button>
) : null}
</p>
<p>
<b>My Name:</b>{' '}
<input
type="text"
value={userProfile.name}
onChange={(e) =>
setUserProfile((profile) => ({...profile, name: e.target.value}))
}
/>{' '}
<input
type="color"
value={userProfile.color}
onChange={(e) =>
setUserProfile((profile) => ({...profile, color: e.target.value}))
}
/>
</p>
<p>
<b>Active users:</b>{' '}
{activeUsers.map(({name, color, userId}, idx) => (
<Fragment key={userId}>
<span style={{color}}>{name}</span>
{idx === activeUsers.length - 1 ? '' : ', '}
</Fragment>
))}
</p>
<LexicalComposer initialConfig={editorConfig}>
{/* With CollaborationPlugin - we MUST NOT use @lexical/react/LexicalHistoryPlugin */}
<CollaborationPlugin
id="lexical/react-rich-collab"
providerFactory={providerFactory}
// Unless you have a way to avoid race condition between 2+ users trying to do bootstrap simultaneously
// you should never try to bootstrap on client. It's better to perform bootstrap within Yjs server.
shouldBootstrap={false}
username={userProfile.name}
cursorColor={userProfile.color}
cursorsContainerRef={containerRef}
/>
<Editor />
</LexicalComposer>
</div>
);
}

View File

@ -0,0 +1,35 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin';
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
import ToolbarPlugin from './plugins/ToolbarPlugin';
import TreeViewPlugin from './plugins/TreeViewPlugin';
function Placeholder() {
return <div className="editor-placeholder">Enter some rich text...</div>;
}
export default function Editor() {
return (
<div className="editor-container">
<ToolbarPlugin />
<div className="editor-inner">
<RichTextPlugin
contentEditable={<ContentEditable className="editor-input" />}
placeholder={<Placeholder />}
ErrorBoundary={LexicalErrorBoundary}
/>
<AutoFocusPlugin />
<TreeViewPlugin />
</div>
</div>
);
}

View File

@ -0,0 +1,42 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
export default {
code: 'editor-code',
heading: {
h1: 'editor-heading-h1',
h2: 'editor-heading-h2',
h3: 'editor-heading-h3',
h4: 'editor-heading-h4',
h5: 'editor-heading-h5',
},
image: 'editor-image',
link: 'editor-link',
list: {
listitem: 'editor-listitem',
nested: {
listitem: 'editor-nested-listitem',
},
ol: 'editor-list-ol',
ul: 'editor-list-ul',
},
ltr: 'ltr',
paragraph: 'editor-paragraph',
placeholder: 'editor-placeholder',
quote: 'editor-quote',
rtl: 'rtl',
text: {
bold: 'editor-text-bold',
code: 'editor-text-code',
hashtag: 'editor-text-hashtag',
italic: 'editor-text-italic',
overflowed: 'editor-text-overflowed',
strikethrough: 'editor-text-strikethrough',
underline: 'editor-text-underline',
underlineStrikethrough: 'editor-text-underlineStrikethrough',
},
};

View File

@ -0,0 +1,39 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
const entries: [string, string][] = [
['Arabian', '#7d0000'],
['Appaloosa', '#640000'],
['Friesian', '#990000'],
['Thoroughbred', '#bf0000'],
['Warmblood', '#bf4000'],
['Saddlebred', '#004000'],
['Mustang', '#007f00'],
['Trakehner', '#407f00'],
['Quarter Horse', '#7f7f00'],
['Clydesdale', '#000099'],
['Paint', '#0000bf'],
['Icelandic', '#0000ff'],
['Andalusian', '#004040'],
['Tennessee Walker', '#404040'],
['Ukrainian Riding Horse', '#7f0040'],
['Percheron', '#bf0040'],
];
export interface UserProfile {
name: string;
color: string;
}
export function getRandomUserProfile(): UserProfile {
const entry = entries[Math.floor(Math.random() * entries.length)];
return {
color: entry[1],
name: entry[0],
};
}

View File

@ -0,0 +1,21 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import './styles.css';
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<div className="App">
<App />
</div>
</React.StrictMode>,
);

View File

@ -0,0 +1,172 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {mergeRegister} from '@lexical/utils';
import {
$getSelection,
$isRangeSelection,
CAN_REDO_COMMAND,
CAN_UNDO_COMMAND,
FORMAT_ELEMENT_COMMAND,
FORMAT_TEXT_COMMAND,
REDO_COMMAND,
SELECTION_CHANGE_COMMAND,
UNDO_COMMAND,
} from 'lexical';
import {useCallback, useEffect, useRef, useState} from 'react';
const LowPriority = 1;
function Divider() {
return <div className="divider" />;
}
export default function ToolbarPlugin() {
const [editor] = useLexicalComposerContext();
const toolbarRef = useRef(null);
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [isBold, setIsBold] = useState(false);
const [isItalic, setIsItalic] = useState(false);
const [isUnderline, setIsUnderline] = useState(false);
const [isStrikethrough, setIsStrikethrough] = useState(false);
const $updateToolbar = useCallback(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
// Update text format
setIsBold(selection.hasFormat('bold'));
setIsItalic(selection.hasFormat('italic'));
setIsUnderline(selection.hasFormat('underline'));
setIsStrikethrough(selection.hasFormat('strikethrough'));
}
}, []);
useEffect(() => {
return mergeRegister(
editor.registerUpdateListener(({editorState}) => {
editorState.read(() => {
$updateToolbar();
});
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
(_payload, _newEditor) => {
$updateToolbar();
return false;
},
LowPriority,
),
editor.registerCommand(
CAN_UNDO_COMMAND,
(payload) => {
setCanUndo(payload);
return false;
},
LowPriority,
),
editor.registerCommand(
CAN_REDO_COMMAND,
(payload) => {
setCanRedo(payload);
return false;
},
LowPriority,
),
);
}, [editor, $updateToolbar]);
return (
<div className="toolbar" ref={toolbarRef}>
<button
disabled={!canUndo}
onClick={() => {
editor.dispatchCommand(UNDO_COMMAND, undefined);
}}
className="toolbar-item spaced"
aria-label="Undo">
<i className="format undo" />
</button>
<button
disabled={!canRedo}
onClick={() => {
editor.dispatchCommand(REDO_COMMAND, undefined);
}}
className="toolbar-item"
aria-label="Redo">
<i className="format redo" />
</button>
<Divider />
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
}}
className={'toolbar-item spaced ' + (isBold ? 'active' : '')}
aria-label="Format Bold">
<i className="format bold" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
}}
className={'toolbar-item spaced ' + (isItalic ? 'active' : '')}
aria-label="Format Italics">
<i className="format italic" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
}}
className={'toolbar-item spaced ' + (isUnderline ? 'active' : '')}
aria-label="Format Underline">
<i className="format underline" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
}}
className={'toolbar-item spaced ' + (isStrikethrough ? 'active' : '')}
aria-label="Format Strikethrough">
<i className="format strikethrough" />
</button>
<Divider />
<button
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left');
}}
className="toolbar-item spaced"
aria-label="Left Align">
<i className="format left-align" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center');
}}
className="toolbar-item spaced"
aria-label="Center Align">
<i className="format center-align" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right');
}}
className="toolbar-item spaced"
aria-label="Right Align">
<i className="format right-align" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify');
}}
className="toolbar-item"
aria-label="Justify Align">
<i className="format justify-align" />
</button>{' '}
</div>
);
}

View File

@ -0,0 +1,25 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {TreeView} from '@lexical/react/LexicalTreeView';
export default function TreeViewPlugin(): JSX.Element {
const [editor] = useLexicalComposerContext();
return (
<TreeView
viewClassName="tree-view-output"
treeTypeButtonClassName="debug-treetype-button"
timeTravelPanelClassName="debug-timetravel-panel"
timeTravelButtonClassName="debug-timetravel-button"
timeTravelPanelSliderClassName="debug-timetravel-panel-slider"
timeTravelPanelButtonClassName="debug-timetravel-panel-button"
editor={editor}
/>
);
}

View File

@ -0,0 +1,62 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {Provider} from '@lexical/yjs';
import {WebrtcProvider} from 'y-webrtc';
import {WebsocketProvider} from 'y-websocket';
import * as Y from 'yjs';
let idSuffix = 0; // In React Strict mode "new WebrtcProvider" may be called twice
/**
* Allows browser windows/tabs to communicate with each other w/o a server (if origin is the same)
* using BroadcastChannel API. Useful for demo purposes.
*/
export function createWebRTCProvider(
id: string,
yjsDocMap: Map<string, Y.Doc>,
): Provider {
const doc = getDocFromMap(id, yjsDocMap);
// localStorage.log = 'true' in browser console to enable logging
const provider = new WebrtcProvider(`${id}/${idSuffix++}`, doc, {
peerOpts: {
reconnectTimer: 100,
},
signaling:
window.location.hostname === 'localhost' ? ['ws://localhost:1235'] : [],
});
// @ts-expect-error TODO: FIXME
return provider;
}
export function createWebsocketProvider(
id: string,
yjsDocMap: Map<string, Y.Doc>,
): Provider {
const doc = getDocFromMap(id, yjsDocMap);
// @ts-expect-error TODO: FIXME
return new WebsocketProvider('ws://localhost:1234', id, doc, {
connect: false,
});
}
function getDocFromMap(id: string, yjsDocMap: Map<string, Y.Doc>): Y.Doc {
let doc = yjsDocMap.get(id);
if (doc === undefined) {
doc = new Y.Doc();
yjsDocMap.set(id, doc);
} else {
doc.load();
}
return doc;
}

View File

@ -0,0 +1,452 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
body {
margin: 0;
font-size: 14px;
background: #eee;
font-family: system-ui, -apple-system, BlinkMacSystemFont, '.SFNSText-Regular',
sans-serif;
font-weight: 500;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
padding-top: 1px;
}
.other h2 {
font-size: 18px;
color: #444;
margin-bottom: 7px;
}
.other a {
color: #777;
text-decoration: underline;
font-size: 14px;
}
.other ul {
padding: 0;
margin: 0;
list-style-type: none;
}
.App {
font-family: sans-serif;
text-align: center;
}
h1 {
font-size: 18px;
color: #333;
}
.ltr {
text-align: left;
}
.rtl {
text-align: right;
}
.editor-container {
margin: 20px auto 20px auto;
border-radius: 2px;
max-width: 600px;
color: #000;
position: relative;
line-height: 20px;
font-weight: 400;
text-align: left;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
}
.editor-inner {
background: #fff;
position: relative;
}
.editor-input {
min-height: 150px;
resize: none;
font-size: 15px;
caret-color: rgb(5, 5, 5);
position: relative;
tab-size: 1;
outline: 0;
padding: 15px 10px;
caret-color: #444;
}
.editor-placeholder {
color: #999;
overflow: hidden;
position: absolute;
text-overflow: ellipsis;
top: 15px;
left: 10px;
font-size: 15px;
user-select: none;
display: inline-block;
pointer-events: none;
}
.editor-text-bold {
font-weight: bold;
}
.editor-text-italic {
font-style: italic;
}
.editor-text-underline {
text-decoration: underline;
}
.editor-text-strikethrough {
text-decoration: line-through;
}
.editor-text-underlineStrikethrough {
text-decoration: underline line-through;
}
.editor-text-code {
background-color: rgb(240, 242, 245);
padding: 1px 0.25rem;
font-family: Menlo, Consolas, Monaco, monospace;
font-size: 94%;
}
.editor-link {
color: rgb(33, 111, 219);
text-decoration: none;
}
.tree-view-output {
display: block;
background: #222;
color: #fff;
padding: 5px;
font-size: 12px;
white-space: pre-wrap;
margin: 1px auto 10px auto;
max-height: 250px;
position: relative;
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
overflow: auto;
line-height: 14px;
}
.editor-code {
background-color: rgb(240, 242, 245);
font-family: Menlo, Consolas, Monaco, monospace;
display: block;
padding: 8px 8px 8px 52px;
line-height: 1.53;
font-size: 13px;
margin: 0;
margin-top: 8px;
margin-bottom: 8px;
tab-size: 2;
/* white-space: pre; */
overflow-x: auto;
position: relative;
}
.editor-code:before {
content: attr(data-gutter);
position: absolute;
background-color: #eee;
left: 0;
top: 0;
border-right: 1px solid #ccc;
padding: 8px;
color: #777;
white-space: pre-wrap;
text-align: right;
min-width: 25px;
}
.editor-code:after {
content: attr(data-highlight-language);
top: 0;
right: 3px;
padding: 3px;
font-size: 10px;
text-transform: uppercase;
position: absolute;
color: rgba(0, 0, 0, 0.5);
}
.editor-tokenComment {
color: slategray;
}
.editor-tokenPunctuation {
color: #999;
}
.editor-tokenProperty {
color: #905;
}
.editor-tokenSelector {
color: #690;
}
.editor-tokenOperator {
color: #9a6e3a;
}
.editor-tokenAttr {
color: #07a;
}
.editor-tokenVariable {
color: #e90;
}
.editor-tokenFunction {
color: #dd4a68;
}
.editor-paragraph {
margin: 0;
margin-bottom: 8px;
position: relative;
}
.editor-paragraph:last-child {
margin-bottom: 0;
}
.editor-heading-h1 {
font-size: 24px;
color: rgb(5, 5, 5);
font-weight: 400;
margin: 0;
margin-bottom: 12px;
padding: 0;
}
.editor-heading-h2 {
font-size: 15px;
color: rgb(101, 103, 107);
font-weight: 700;
margin: 0;
margin-top: 10px;
padding: 0;
text-transform: uppercase;
}
.editor-quote {
margin: 0;
margin-left: 20px;
font-size: 15px;
color: rgb(101, 103, 107);
border-left-color: rgb(206, 208, 212);
border-left-width: 4px;
border-left-style: solid;
padding-left: 16px;
}
.editor-list-ol {
padding: 0;
margin: 0;
margin-left: 16px;
}
.editor-list-ul {
padding: 0;
margin: 0;
margin-left: 16px;
}
.editor-listitem {
margin: 8px 32px 8px 32px;
}
.editor-nested-listitem {
list-style-type: none;
}
pre::-webkit-scrollbar {
background: transparent;
width: 10px;
}
pre::-webkit-scrollbar-thumb {
background: #999;
}
.debug-timetravel-panel {
overflow: hidden;
padding: 0 0 10px 0;
margin: auto;
display: flex;
}
.debug-timetravel-panel-slider {
padding: 0;
flex: 8;
}
.debug-timetravel-panel-button {
padding: 0;
border: 0;
background: none;
flex: 1;
color: #fff;
font-size: 12px;
}
.debug-timetravel-panel-button:hover {
text-decoration: underline;
}
.debug-timetravel-button {
border: 0;
padding: 0;
font-size: 12px;
top: 10px;
right: 15px;
position: absolute;
background: none;
color: #fff;
}
.debug-timetravel-button:hover {
text-decoration: underline;
}
.toolbar {
display: flex;
margin-bottom: 1px;
background: #fff;
padding: 4px;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
vertical-align: middle;
}
.toolbar button.toolbar-item {
border: 0;
display: flex;
background: none;
border-radius: 10px;
padding: 8px;
cursor: pointer;
vertical-align: middle;
}
.toolbar button.toolbar-item:disabled {
cursor: not-allowed;
}
.toolbar button.toolbar-item.spaced {
margin-right: 2px;
}
.toolbar button.toolbar-item i.format {
background-size: contain;
display: inline-block;
height: 18px;
width: 18px;
margin-top: 2px;
vertical-align: -0.25em;
display: flex;
opacity: 0.6;
}
.toolbar button.toolbar-item:disabled i.format {
opacity: 0.2;
}
.toolbar button.toolbar-item.active {
background-color: rgba(223, 232, 250, 0.3);
}
.toolbar button.toolbar-item.active i {
opacity: 1;
}
.toolbar .toolbar-item:hover:not([disabled]) {
background-color: #eee;
}
.toolbar .divider {
width: 1px;
background-color: #eee;
margin: 0 4px;
}
.toolbar .toolbar-item .text {
display: flex;
line-height: 20px;
width: 200px;
vertical-align: middle;
font-size: 14px;
color: #777;
text-overflow: ellipsis;
width: 70px;
overflow: hidden;
height: 20px;
text-align: left;
}
.toolbar .toolbar-item .icon {
display: flex;
width: 20px;
height: 20px;
user-select: none;
margin-right: 8px;
line-height: 16px;
background-size: contain;
}
i.undo {
background-image: url(/icons/arrow-counterclockwise.svg);
}
i.redo {
background-image: url(/icons/arrow-clockwise.svg);
}
i.bold {
background-image: url(/icons/type-bold.svg);
}
i.italic {
background-image: url(/icons/type-italic.svg);
}
i.underline {
background-image: url(/icons/type-underline.svg);
}
i.strikethrough {
background-image: url(/icons/type-strikethrough.svg);
}
i.left-align {
background-image: url(/icons/text-left.svg);
}
i.center-align {
background-image: url(/icons/text-center.svg);
}
i.right-align {
background-image: url(/icons/text-right.svg);
}
i.justify-align {
background-image: url(/icons/justify.svg);
}

View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{"path": "./tsconfig.node.json"}]
}

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,23 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import react from '@vitejs/plugin-react';
import {resolve} from 'path';
import {defineConfig} from 'vite';
// https://vitejs.dev/config/
export default defineConfig({
build: {
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
nested: resolve(__dirname, 'app.html'),
},
},
},
plugins: [react()],
});

View File

@ -4,25 +4,52 @@ sidebar_position: 1
# React
Below is an example of a basic plain text editor using `lexical`, `@lexical/react`, and [`yjs`](https://github.com/yjs/yjs)
Lexical provides `LexicalCollaborationPlugin` and `useCollaborationContext` hook within `@lexical/react` to accelerate creation of the collaborative React backed editors.
This is on top of the Yjs bindings provided by `@lexical/yjs`.
```jsx
:::tip
Clone [Lexical GitHub repo](https://github.com/facebook/lexical), run `npm i && npm run start` and open [`http://localhost:3000/split/?isCollab=true`](http://localhost:3000/split/?isCollab=true) to launch playground in collaborative mode.
:::
## Getting started
This guide is based on [examples/react-rich](https://github.com/facebook/lexical/tree/main/examples/react-rich) example.
**Install minimal set of the required dependencies:**
```bash
$ npm i -S @lexical/react @lexical/yjs lexical react react-dom y-websocket yjs
```
:::note
`y-websocket` is the only officially supported Yjs connection provider at this point. Although other providers may work just fine.
:::
**Get WebSocket server running:**
This allows different browser windows and different borwsers to find each other and sync Lexical state. On top of this `YPERSISTENCE` allows you to save Yjs documents in between server restarts so clients can simply reconnect and keep editing.
```bash
$ HOST=localhost PORT=1234 YPERSISTENCE=./yjs-wss-db npx y-websocket
```
**Get basic collaborative Lexical setup:**
```tsx
import {$getRoot, $createParagraphNode, $createTextNode} from 'lexical';
import {LexicalComposer} from '@lexical/react/LexicalComposer';
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
import {PlainTextPlugin} from '@lexical/react/LexicalPlainTextPlugin';
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
import {CollaborationPlugin} from '@lexical/react/LexicalCollaborationPlugin';
import * as Y from 'yjs';
import {$initialEditorState} from './initialEditorState';
import {WebsocketProvider} from 'y-websocket';
function initialEditorState(editor: LexicalEditor): void {
const root = $getRoot();
const paragraph = $createParagraphNode();
const text = $createTextNode('Welcome to collab!');
paragraph.append(text);
root.append(paragraph);
}
function Editor() {
const initialConfig = {
// NOTE: This is critical for collaboration plugin to set editor state to null. It
@ -37,31 +64,31 @@ function Editor() {
theme: {},
};
const providerFactory = useCallback(
(id: string, yjsDocMap: Map<string, Y.Doc>) => {
const doc = getDocFromMap(id, yjsDocMap);
return new WebsocketProvider('ws://localhost:1234', id, doc, {
connect: false,
});
}, [],
);
return (
<LexicalComposer initialConfig={initialConfig}>
<PlainTextPlugin
contentEditable={<ContentEditable />}
placeholder={<div>Enter some text...</div>}
<RichTextPlugin
contentEditable={<ContentEditable className="editor-input" />}
placeholder={<div className="editor-placeholder">Enter some rich text...</div>}
ErrorBoundary={LexicalErrorBoundary}
/>
<CollaborationPlugin
id="yjs-plugin"
providerFactory={(id, yjsDocMap) => {
const doc = new Y.Doc();
yjsDocMap.set(id, doc);
const provider = new WebsocketProvider(
"ws://localhost:1234",
id,
doc
);
return provider;
}}
id="lexical/react-rich-collab"
providerFactory={providerFactory}
// Optional initial editor state in case collaborative Y.Doc won't
// have any existing data on server. Then it'll user this value to populate editor.
// have any existing data on server. Then it'll user this value to populate editor.
// It accepts same type of values as LexicalComposer editorState
// prop (json string, state object, or a function)
initialEditorState={initialEditorState}
initialEditorState={$initialEditorState}
shouldBootstrap={true}
/>
</LexicalComposer>
@ -69,12 +96,29 @@ function Editor() {
}
```
## See it in action
Source code: [examples/react-rich-collab](https://github.com/facebook/lexical/tree/main/examples/react-rich-collab)
<iframe width="100%" height="600" src="https://stackblitz.com/github/facebook/lexical/tree/fix/collab_example/examples/react-rich-collab?embed=1&file=src%2FApp.tsx&terminalHeight=0&ctl=1&showSidebar=0&devtoolsheight=0&view=preview" sandbox="allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-presentation allow-same-origin allow-scripts"></iframe>
## Building collaborative plugins
[Lexical Playground](https://playground.lexical.dev/) features set of the collaboration enabled plugins that integrate with primary document via `useCollaborationContext()` hook. Notable mentions:
- [`CommentPlugin`](https://github.com/facebook/lexical/tree/v0.14.5/packages/lexical-playground/src/plugins/CommentPlugin) - features use of the separate provider and Yjs room to sync comments.
- [`ImageComponent`](https://github.com/facebook/lexical/blob/v0.14.5/packages/lexical-playground/src/nodes/ImageComponent.tsx#L390) - features use of the `LexicalNestedComposer` paired with `CollaborationPlugin`.
- [`PollOptionComponent`](https://github.com/facebook/lexical/blob/v0.14.5/packages/lexical-playground/src/nodes/PollComponent.tsx#L78) - showcases poll implementation using `clientID` from Yjs context.
- [`StickyPlugin`](https://github.com/facebook/lexical/tree/v0.14.5/packages/lexical-playground/src/plugins/StickyPlugin) - features use of the `LexicalNestedComposer` paired with `CollaborationPlugin` as well as sticky note position real-time sync.
:::note
While these "playground" plugins aren't production ready - they serve as a great example of collaborative Lexical capabilities as well as provide a good starting point.
:::
## Yjs providers
Setting up the communication between clients, managing awareness information, and storing shared data for offline usage is quite a hassle. Providers manage all that for you and are the perfect starting point for your collaborative app.
- [y-webrtc](https://github.com/yjs/y-webrtc)
- [y-websocket](https://github.com/yjs/y-websocket)
- [y-indexeddb](https://github.com/yjs/y-indexeddb)
- [@liveblocks/yjs](https://liveblocks.io/docs/api-reference/liveblocks-yjs)
See [Yjs Website](https://docs.yjs.dev/ecosystem/connection-provider) for the list of the officially endorsed providers. Although it's not an exhaustive one.