@lexical/react
Version:
This package provides Lexical components and hooks for React applications.
382 lines (373 loc) • 12.7 kB
JavaScript
/**
* 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 { useCollaborationContext } from '@lexical/react/LexicalCollaborationContext';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { initLocalState, syncLexicalUpdateToYjs, TOGGLE_CONNECT_COMMAND, syncCursorPositions, setLocalStateFocus, createUndoManager, CONNECTED_COMMAND, syncYjsChangesToLexical, createBinding } from '@lexical/yjs';
import * as React from 'react';
import { useRef, useCallback, useEffect, useMemo, useState } from 'react';
import { mergeRegister } from '@lexical/utils';
import { COMMAND_PRIORITY_EDITOR, FOCUS_COMMAND, BLUR_COMMAND, UNDO_COMMAND, REDO_COMMAND, CAN_UNDO_COMMAND, CAN_REDO_COMMAND, $getRoot, $createParagraphNode, $getSelection } from 'lexical';
import { createPortal } from 'react-dom';
import { UndoManager } from 'yjs';
import { jsx, Fragment } from 'react/jsx-runtime';
/**
* 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.
*
*/
function useYjsCollaboration(editor, id, provider, docMap, name, color, shouldBootstrap, binding, setDoc, cursorsContainerRef, initialEditorState, awarenessData, syncCursorPositionsFn = syncCursorPositions) {
const isReloadingDoc = useRef(false);
const connect = useCallback(() => provider.connect(), [provider]);
const disconnect = useCallback(() => {
try {
provider.disconnect();
} catch (e) {
// Do nothing
}
}, [provider]);
useEffect(() => {
const {
root
} = binding;
const {
awareness
} = provider;
const onStatus = ({
status
}) => {
editor.dispatchCommand(CONNECTED_COMMAND, status === 'connected');
};
const onSync = isSynced => {
if (shouldBootstrap && isSynced && root.isEmpty() && root._xmlText._length === 0 && isReloadingDoc.current === false) {
initializeEditor(editor, initialEditorState);
}
isReloadingDoc.current = false;
};
const onAwarenessUpdate = () => {
syncCursorPositionsFn(binding, provider);
};
const onYjsTreeChanges = (events, transaction) => {
const origin = transaction.origin;
if (origin !== binding) {
const isFromUndoManger = origin instanceof UndoManager;
syncYjsChangesToLexical(binding, provider, events, isFromUndoManger, syncCursorPositionsFn);
}
};
initLocalState(provider, name, color, document.activeElement === editor.getRootElement(), awarenessData || {});
const onProviderDocReload = ydoc => {
clearEditorSkipCollab(editor, binding);
setDoc(ydoc);
docMap.set(id, ydoc);
isReloadingDoc.current = true;
};
provider.on('reload', onProviderDocReload);
provider.on('status', onStatus);
provider.on('sync', onSync);
awareness.on('update', onAwarenessUpdate);
// This updates the local editor state when we receive updates from other clients
root.getSharedType().observeDeep(onYjsTreeChanges);
const removeListener = editor.registerUpdateListener(({
prevEditorState,
editorState,
dirtyLeaves,
dirtyElements,
normalizedNodes,
tags
}) => {
if (tags.has('skip-collab') === false) {
syncLexicalUpdateToYjs(binding, provider, prevEditorState, editorState, dirtyElements, dirtyLeaves, normalizedNodes, tags);
}
});
const connectionPromise = connect();
return () => {
if (isReloadingDoc.current === false) {
if (connectionPromise) {
connectionPromise.then(disconnect);
} else {
// Workaround for race condition in StrictMode. It's possible there
// is a different race for the above case where connect returns a
// promise, but we don't have an example of that in-repo.
// It's possible that there is a similar issue with
// TOGGLE_CONNECT_COMMAND below when the provider connect returns a
// promise.
// https://github.com/facebook/lexical/issues/6640
disconnect();
}
}
provider.off('sync', onSync);
provider.off('status', onStatus);
provider.off('reload', onProviderDocReload);
awareness.off('update', onAwarenessUpdate);
root.getSharedType().unobserveDeep(onYjsTreeChanges);
docMap.delete(id);
removeListener();
};
}, [binding, color, connect, disconnect, docMap, editor, id, initialEditorState, name, provider, shouldBootstrap, awarenessData, setDoc, syncCursorPositionsFn]);
const cursorsContainer = useMemo(() => {
const ref = element => {
binding.cursorsContainer = element;
};
return /*#__PURE__*/createPortal(/*#__PURE__*/jsx("div", {
ref: ref
}), cursorsContainerRef && cursorsContainerRef.current || document.body);
}, [binding, cursorsContainerRef]);
useEffect(() => {
return editor.registerCommand(TOGGLE_CONNECT_COMMAND, payload => {
const shouldConnect = payload;
if (shouldConnect) {
// eslint-disable-next-line no-console
console.log('Collaboration connected!');
connect();
} else {
// eslint-disable-next-line no-console
console.log('Collaboration disconnected!');
disconnect();
}
return true;
}, COMMAND_PRIORITY_EDITOR);
}, [connect, disconnect, editor]);
return cursorsContainer;
}
function useYjsFocusTracking(editor, provider, name, color, awarenessData) {
useEffect(() => {
return mergeRegister(editor.registerCommand(FOCUS_COMMAND, () => {
setLocalStateFocus(provider, name, color, true, awarenessData || {});
return false;
}, COMMAND_PRIORITY_EDITOR), editor.registerCommand(BLUR_COMMAND, () => {
setLocalStateFocus(provider, name, color, false, awarenessData || {});
return false;
}, COMMAND_PRIORITY_EDITOR));
}, [color, editor, name, provider, awarenessData]);
}
function useYjsHistory(editor, binding) {
const undoManager = useMemo(() => createUndoManager(binding, binding.root.getSharedType()), [binding]);
useEffect(() => {
const undo = () => {
undoManager.undo();
};
const redo = () => {
undoManager.redo();
};
return mergeRegister(editor.registerCommand(UNDO_COMMAND, () => {
undo();
return true;
}, COMMAND_PRIORITY_EDITOR), editor.registerCommand(REDO_COMMAND, () => {
redo();
return true;
}, COMMAND_PRIORITY_EDITOR));
});
const clearHistory = useCallback(() => {
undoManager.clear();
}, [undoManager]);
// Exposing undo and redo states
React.useEffect(() => {
const updateUndoRedoStates = () => {
editor.dispatchCommand(CAN_UNDO_COMMAND, undoManager.undoStack.length > 0);
editor.dispatchCommand(CAN_REDO_COMMAND, undoManager.redoStack.length > 0);
};
undoManager.on('stack-item-added', updateUndoRedoStates);
undoManager.on('stack-item-popped', updateUndoRedoStates);
undoManager.on('stack-cleared', updateUndoRedoStates);
return () => {
undoManager.off('stack-item-added', updateUndoRedoStates);
undoManager.off('stack-item-popped', updateUndoRedoStates);
undoManager.off('stack-cleared', updateUndoRedoStates);
};
}, [editor, undoManager]);
return clearHistory;
}
function initializeEditor(editor, initialEditorState) {
editor.update(() => {
const root = $getRoot();
if (root.isEmpty()) {
if (initialEditorState) {
switch (typeof initialEditorState) {
case 'string':
{
const parsedEditorState = editor.parseEditorState(initialEditorState);
editor.setEditorState(parsedEditorState, {
tag: 'history-merge'
});
break;
}
case 'object':
{
editor.setEditorState(initialEditorState, {
tag: 'history-merge'
});
break;
}
case 'function':
{
editor.update(() => {
const root1 = $getRoot();
if (root1.isEmpty()) {
initialEditorState(editor);
}
}, {
tag: 'history-merge'
});
break;
}
}
} else {
const paragraph = $createParagraphNode();
root.append(paragraph);
const {
activeElement
} = document;
if ($getSelection() !== null || activeElement !== null && activeElement === editor.getRootElement()) {
paragraph.select();
}
}
}
}, {
tag: 'history-merge'
});
}
function clearEditorSkipCollab(editor, binding) {
// reset editor state
editor.update(() => {
const root = $getRoot();
root.clear();
root.select();
}, {
tag: 'skip-collab'
});
if (binding.cursors == null) {
return;
}
const cursors = binding.cursors;
if (cursors == null) {
return;
}
const cursorsContainer = binding.cursorsContainer;
if (cursorsContainer == null) {
return;
}
// reset cursors in dom
const cursorsArr = Array.from(cursors.values());
for (let i = 0; i < cursorsArr.length; i++) {
const cursor = cursorsArr[i];
const selection = cursor.selection;
if (selection && selection.selections != null) {
const selections = selection.selections;
for (let j = 0; j < selections.length; j++) {
cursorsContainer.removeChild(selections[i]);
}
}
}
}
/**
* 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.
*
*/
function CollaborationPlugin({
id,
providerFactory,
shouldBootstrap,
username,
cursorColor,
cursorsContainerRef,
initialEditorState,
excludedProperties,
awarenessData
}) {
const isBindingInitialized = useRef(false);
const isProviderInitialized = useRef(false);
const collabContext = useCollaborationContext(username, cursorColor);
const {
yjsDocMap,
name,
color
} = collabContext;
const [editor] = useLexicalComposerContext();
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();
useEffect(() => {
if (isProviderInitialized.current) {
return;
}
isProviderInitialized.current = true;
const newProvider = providerFactory(id, yjsDocMap);
setProvider(newProvider);
return () => {
newProvider.disconnect();
};
}, [id, providerFactory, yjsDocMap]);
const [doc, setDoc] = useState(yjsDocMap.get(id));
const [binding, setBinding] = useState();
useEffect(() => {
if (!provider) {
return;
}
if (isBindingInitialized.current) {
return;
}
isBindingInitialized.current = true;
const newBinding = createBinding(editor, provider, id, doc || yjsDocMap.get(id), yjsDocMap, excludedProperties);
setBinding(newBinding);
return () => {
newBinding.root.destroy(newBinding);
};
}, [editor, provider, id, yjsDocMap, doc, excludedProperties]);
if (!provider || !binding) {
return /*#__PURE__*/jsx(Fragment, {});
}
return /*#__PURE__*/jsx(YjsCollaborationCursors, {
awarenessData: awarenessData,
binding: binding,
collabContext: collabContext,
color: color,
cursorsContainerRef: cursorsContainerRef,
editor: editor,
id: id,
initialEditorState: initialEditorState,
name: name,
provider: provider,
setDoc: setDoc,
shouldBootstrap: shouldBootstrap,
yjsDocMap: yjsDocMap
});
}
function YjsCollaborationCursors({
editor,
id,
provider,
yjsDocMap,
name,
color,
shouldBootstrap,
cursorsContainerRef,
initialEditorState,
awarenessData,
collabContext,
binding,
setDoc
}) {
const cursors = useYjsCollaboration(editor, id, provider, yjsDocMap, name, color, shouldBootstrap, binding, setDoc, cursorsContainerRef, initialEditorState, awarenessData);
collabContext.clientID = binding.clientID;
useYjsHistory(editor, binding);
useYjsFocusTracking(editor, provider, name, color, awarenessData);
return cursors;
}
export { CollaborationPlugin };