Skip to content

[WIP][lexical-yjs][lexical-react] Feature: initial implementation of collab v2 #7616

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
focusEditor,
html,
initialize,
IS_MAC,
sleep,
test,
} from '../utils/index.mjs';
Expand All @@ -38,7 +37,7 @@ test.describe('Collaboration', () => {
isCollab,
browserName,
}) => {
test.skip(!isCollab || IS_MAC);
test.skip(!isCollab);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drive-by: this test works fine on mac


await focusEditor(page);
await page.keyboard.type('hello');
Expand Down
23 changes: 17 additions & 6 deletions packages/lexical-playground/src/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import {CharacterLimitPlugin} from '@lexical/react/LexicalCharacterLimitPlugin';
import {CheckListPlugin} from '@lexical/react/LexicalCheckListPlugin';
import {ClearEditorPlugin} from '@lexical/react/LexicalClearEditorPlugin';
import {ClickableLinkPlugin} from '@lexical/react/LexicalClickableLinkPlugin';
import {CollaborationPlugin} from '@lexical/react/LexicalCollaborationPlugin';
import {
CollaborationPlugin,
CollaborationPluginV2__EXPERIMENTAL,
} from '@lexical/react/LexicalCollaborationPlugin';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
import {HashtagPlugin} from '@lexical/react/LexicalHashtagPlugin';
Expand Down Expand Up @@ -85,6 +88,7 @@ export default function Editor(): JSX.Element {
const {
settings: {
isCollab,
useCollabV2,
isAutocomplete,
isMaxLength,
isCharLimit,
Expand Down Expand Up @@ -180,11 +184,18 @@ export default function Editor(): JSX.Element {
{isRichText ? (
<>
{isCollab ? (
<CollaborationPlugin
id="main"
providerFactory={createWebsocketProvider}
shouldBootstrap={!skipCollaborationInit}
/>
useCollabV2 ? (
<CollaborationPluginV2__EXPERIMENTAL
id="main"
providerFactory={createWebsocketProvider}
/>
) : (
<CollaborationPlugin
id="main"
providerFactory={createWebsocketProvider}
shouldBootstrap={!skipCollaborationInit}
/>
)
) : (
<HistoryPlugin externalHistoryState={historyState} />
)}
Expand Down
1 change: 1 addition & 0 deletions packages/lexical-playground/src/appSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const DEFAULT_SETTINGS = {
tableCellBackgroundColor: true,
tableCellMerge: true,
tableHorizontalScroll: true,
useCollabV2: false,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding as a setting but not exposing it through the settings menu yet, given how early days this still is.

} as const;

// These are mutated in setupEnv
Expand Down
216 changes: 180 additions & 36 deletions packages/lexical-react/src/LexicalCollaborationPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import {
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {
Binding,
BindingV2,
createBinding,
createBindingV2__EXPERIMENTAL,
ExcludedProperties,
Provider,
SyncCursorPositionsFn,
Expand All @@ -28,17 +30,17 @@ import {InitialEditorStateType} from './LexicalComposer';
import {
CursorsContainerRef,
useYjsCollaboration,
useYjsCollaborationV2__EXPERIMENTAL,
useYjsFocusTracking,
useYjsHistory,
useYjsHistoryV2,
} from './shared/useYjsCollaboration';

type Props = {
type ProviderFactory = (id: string, yjsDocMap: Map<string, Doc>) => Provider;

type CollaborationPluginProps = {
id: string;
providerFactory: (
// eslint-disable-next-line no-shadow
id: string,
yjsDocMap: Map<string, Doc>,
) => Provider;
providerFactory: ProviderFactory;
shouldBootstrap: boolean;
username?: string;
cursorColor?: string;
Expand All @@ -61,44 +63,17 @@ export function CollaborationPlugin({
excludedProperties,
awarenessData,
syncCursorPositionsFn,
}: Props): JSX.Element {
}: CollaborationPluginProps): JSX.Element {
const isBindingInitialized = useRef(false);
const isProviderInitialized = useRef(false);

const collabContext = useCollaborationContext(username, cursorColor);

const {yjsDocMap, name, color} = collabContext;

const [editor] = useLexicalComposerContext();
useCollabActive(collabContext, editor);

useEffect(() => {
collabContext.isCollabActive = true;

return () => {
// Resetting flag only when unmount top level editor collab plugin. Nested
// editors (e.g. image caption) should unmount without affecting it
if (editor._parentEditor == null) {
collabContext.isCollabActive = false;
}
};
}, [collabContext, editor]);

const [provider, setProvider] = useState<Provider>();

useEffect(() => {
if (isProviderInitialized.current) {
return;
}

isProviderInitialized.current = true;

const newProvider = providerFactory(id, yjsDocMap);
setProvider(newProvider);

return () => {
newProvider.disconnect();
};
}, [id, providerFactory, yjsDocMap]);
const provider = useProvider(id, yjsDocMap, providerFactory);

const [doc, setDoc] = useState(yjsDocMap.get(id));
const [binding, setBinding] = useState<Binding>();
Expand Down Expand Up @@ -207,3 +182,172 @@ function YjsCollaborationCursors({

return cursors;
}

type CollaborationPluginV2Props = {
id: string;
providerFactory: ProviderFactory;
username?: string;
cursorColor?: string;
cursorsContainerRef?: CursorsContainerRef;
excludedProperties?: ExcludedProperties;
// `awarenessData` parameter allows arbitrary data to be added to the awareness.
awarenessData?: object;
};

export function CollaborationPluginV2__EXPERIMENTAL({
id,
providerFactory,
username,
cursorColor,
cursorsContainerRef,
excludedProperties,
awarenessData,
}: CollaborationPluginV2Props): JSX.Element {
const isBindingInitialized = useRef(false);

const collabContext = useCollaborationContext(username, cursorColor);

const {yjsDocMap, name, color} = collabContext;

const [editor] = useLexicalComposerContext();
useCollabActive(collabContext, editor);

const provider = useProvider(id, yjsDocMap, providerFactory);

const [doc, setDoc] = useState(yjsDocMap.get(id));
const [binding, setBinding] = useState<BindingV2>();

useEffect(() => {
if (!provider) {
return;
}

if (isBindingInitialized.current) {
return;
}

isBindingInitialized.current = true;

const newBinding = createBindingV2__EXPERIMENTAL(
editor,
id,
doc || yjsDocMap.get(id),
yjsDocMap,
excludedProperties,
);
setBinding(newBinding);
}, [editor, provider, id, yjsDocMap, doc, excludedProperties]);

if (!provider || !binding) {
return <></>;
}

return (
<YjsCollaborationCursorsV2__EXPERIMENTAL
awarenessData={awarenessData}
binding={binding}
collabContext={collabContext}
color={color}
cursorsContainerRef={cursorsContainerRef}
editor={editor}
id={id}
name={name}
provider={provider}
setDoc={setDoc}
shouldBootstrap={false}
yjsDocMap={yjsDocMap}
/>
);
}

function YjsCollaborationCursorsV2__EXPERIMENTAL({
editor,
id,
provider,
yjsDocMap,
name,
color,
cursorsContainerRef,
awarenessData,
collabContext,
binding,
setDoc,
}: {
editor: LexicalEditor;
id: string;
provider: Provider;
yjsDocMap: Map<string, Doc>;
name: string;
color: string;
shouldBootstrap: boolean;
binding: BindingV2;
setDoc: React.Dispatch<React.SetStateAction<Doc | undefined>>;
cursorsContainerRef?: CursorsContainerRef | undefined;
initialEditorState?: InitialEditorStateType | undefined;
awarenessData?: object;
collabContext: CollaborationContextType;
syncCursorPositionsFn?: SyncCursorPositionsFn;
}) {
const cursors = useYjsCollaborationV2__EXPERIMENTAL(
editor,
id,
provider,
yjsDocMap,
name,
color,
binding,
setDoc,
cursorsContainerRef,
awarenessData,
);

collabContext.clientID = binding.clientID;

useYjsHistoryV2(editor, binding);
useYjsFocusTracking(editor, provider, name, color, awarenessData);

return cursors;
}

const useCollabActive = (
collabContext: CollaborationContextType,
editor: LexicalEditor,
) => {
useEffect(() => {
collabContext.isCollabActive = true;

return () => {
// Resetting flag only when unmount top level editor collab plugin. Nested
// editors (e.g. image caption) should unmount without affecting it
if (editor._parentEditor == null) {
collabContext.isCollabActive = false;
}
};
}, [collabContext, editor]);
};

const useProvider = (
id: string,
yjsDocMap: Map<string, Doc>,
providerFactory: ProviderFactory,
) => {
const isProviderInitialized = useRef(false);
const [provider, setProvider] = useState<Provider>();

useEffect(() => {
if (isProviderInitialized.current) {
return;
}

isProviderInitialized.current = true;

const newProvider = providerFactory(id, yjsDocMap);
setProvider(newProvider);

return () => {
newProvider.disconnect();
};
}, [id, providerFactory, yjsDocMap]);

return provider;
};
Loading