feat(examples/react-rich-collab): Added collaboration focused example (#6043)
1
examples/react-rich-collab/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/yjs-wss-db
|
7
examples/react-rich-collab/README.md
Normal 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`
|
||||
|
||||
[](https://stackblitz.com/github/facebook/lexical/tree/fix/collab_example/examples/react-rich-collab?file=src/main.ts)
|
12
examples/react-rich-collab/app.html
Normal 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>
|
37
examples/react-rich-collab/index.html
Normal 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>
|
4848
examples/react-rich-collab/package-lock.json
generated
Normal file
33
examples/react-rich-collab/package.json
Normal 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"
|
||||
}
|
||||
}
|
5
examples/react-rich-collab/public/icons/LICENSE.md
Normal file
@ -0,0 +1,5 @@
|
||||
Bootstrap Icons
|
||||
https://icons.getbootstrap.com
|
||||
|
||||
Licensed under MIT license
|
||||
https://github.com/twbs/icons/blob/main/LICENSE.md
|
@ -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 |
@ -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 |
5
examples/react-rich-collab/public/icons/journal-text.svg
Normal 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 |
3
examples/react-rich-collab/public/icons/justify.svg
Normal 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 |
3
examples/react-rich-collab/public/icons/text-center.svg
Normal 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 |
3
examples/react-rich-collab/public/icons/text-left.svg
Normal 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 |
@ -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 |
3
examples/react-rich-collab/public/icons/text-right.svg
Normal 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 |
3
examples/react-rich-collab/public/icons/type-bold.svg
Normal 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 |
3
examples/react-rich-collab/public/icons/type-italic.svg
Normal 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 |
@ -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 |
@ -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 |
170
examples/react-rich-collab/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
35
examples/react-rich-collab/src/Editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
42
examples/react-rich-collab/src/ExampleTheme.ts
Normal 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',
|
||||
},
|
||||
};
|
39
examples/react-rich-collab/src/getRandomUserProfile.ts
Normal 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],
|
||||
};
|
||||
}
|
21
examples/react-rich-collab/src/main.tsx
Normal 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>,
|
||||
);
|
172
examples/react-rich-collab/src/plugins/ToolbarPlugin.tsx
Normal 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>
|
||||
);
|
||||
}
|
25
examples/react-rich-collab/src/plugins/TreeViewPlugin.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
62
examples/react-rich-collab/src/providers.ts
Normal 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;
|
||||
}
|
452
examples/react-rich-collab/src/styles.css
Normal 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);
|
||||
}
|
1
examples/react-rich-collab/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
25
examples/react-rich-collab/tsconfig.json
Normal 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"}]
|
||||
}
|
11
examples/react-rich-collab/tsconfig.node.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
23
examples/react-rich-collab/vite.config.ts
Normal 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()],
|
||||
});
|
@ -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.
|
||||
|