@lexical/react
Version:
This package provides Lexical components and hooks for React applications.
396 lines (384 loc) • 13 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.
*
*/
;
var LexicalCollaborationContext = require('@lexical/react/LexicalCollaborationContext');
var LexicalComposerContext = require('@lexical/react/LexicalComposerContext');
var yjs = require('@lexical/yjs');
var React = require('react');
var utils = require('@lexical/utils');
var lexical = require('lexical');
var reactDom = require('react-dom');
var yjs$1 = require('yjs');
var jsxRuntime = require('react/jsx-runtime');
function _interopNamespaceDefault(e) {
var n = Object.create(null);
if (e) {
for (var k in e) {
n[k] = e[k];
}
}
n.default = e;
return n;
}
var React__namespace = /*#__PURE__*/_interopNamespaceDefault(React);
/**
* 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 = yjs.syncCursorPositions) {
const isReloadingDoc = React.useRef(false);
const connect = React.useCallback(() => provider.connect(), [provider]);
const disconnect = React.useCallback(() => {
try {
provider.disconnect();
} catch (e) {
// Do nothing
}
}, [provider]);
React.useEffect(() => {
const {
root
} = binding;
const {
awareness
} = provider;
const onStatus = ({
status
}) => {
editor.dispatchCommand(yjs.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 yjs$1.UndoManager;
yjs.syncYjsChangesToLexical(binding, provider, events, isFromUndoManger, syncCursorPositionsFn);
}
};
yjs.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) {
yjs.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 = React.useMemo(() => {
const ref = element => {
binding.cursorsContainer = element;
};
return /*#__PURE__*/reactDom.createPortal(/*#__PURE__*/jsxRuntime.jsx("div", {
ref: ref
}), cursorsContainerRef && cursorsContainerRef.current || document.body);
}, [binding, cursorsContainerRef]);
React.useEffect(() => {
return editor.registerCommand(yjs.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;
}, lexical.COMMAND_PRIORITY_EDITOR);
}, [connect, disconnect, editor]);
return cursorsContainer;
}
function useYjsFocusTracking(editor, provider, name, color, awarenessData) {
React.useEffect(() => {
return utils.mergeRegister(editor.registerCommand(lexical.FOCUS_COMMAND, () => {
yjs.setLocalStateFocus(provider, name, color, true, awarenessData || {});
return false;
}, lexical.COMMAND_PRIORITY_EDITOR), editor.registerCommand(lexical.BLUR_COMMAND, () => {
yjs.setLocalStateFocus(provider, name, color, false, awarenessData || {});
return false;
}, lexical.COMMAND_PRIORITY_EDITOR));
}, [color, editor, name, provider, awarenessData]);
}
function useYjsHistory(editor, binding) {
const undoManager = React.useMemo(() => yjs.createUndoManager(binding, binding.root.getSharedType()), [binding]);
React.useEffect(() => {
const undo = () => {
undoManager.undo();
};
const redo = () => {
undoManager.redo();
};
return utils.mergeRegister(editor.registerCommand(lexical.UNDO_COMMAND, () => {
undo();
return true;
}, lexical.COMMAND_PRIORITY_EDITOR), editor.registerCommand(lexical.REDO_COMMAND, () => {
redo();
return true;
}, lexical.COMMAND_PRIORITY_EDITOR));
});
const clearHistory = React.useCallback(() => {
undoManager.clear();
}, [undoManager]);
// Exposing undo and redo states
React__namespace.useEffect(() => {
const updateUndoRedoStates = () => {
editor.dispatchCommand(lexical.CAN_UNDO_COMMAND, undoManager.undoStack.length > 0);
editor.dispatchCommand(lexical.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 = lexical.$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 = lexical.$getRoot();
if (root1.isEmpty()) {
initialEditorState(editor);
}
}, {
tag: 'history-merge'
});
break;
}
}
} else {
const paragraph = lexical.$createParagraphNode();
root.append(paragraph);
const {
activeElement
} = document;
if (lexical.$getSelection() !== null || activeElement !== null && activeElement === editor.getRootElement()) {
paragraph.select();
}
}
}
}, {
tag: 'history-merge'
});
}
function clearEditorSkipCollab(editor, binding) {
// reset editor state
editor.update(() => {
const root = lexical.$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 = React.useRef(false);
const isProviderInitialized = React.useRef(false);
const collabContext = LexicalCollaborationContext.useCollaborationContext(username, cursorColor);
const {
yjsDocMap,
name,
color
} = collabContext;
const [editor] = LexicalComposerContext.useLexicalComposerContext();
React.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] = React.useState();
React.useEffect(() => {
if (isProviderInitialized.current) {
return;
}
isProviderInitialized.current = true;
const newProvider = providerFactory(id, yjsDocMap);
setProvider(newProvider);
return () => {
newProvider.disconnect();
};
}, [id, providerFactory, yjsDocMap]);
const [doc, setDoc] = React.useState(yjsDocMap.get(id));
const [binding, setBinding] = React.useState();
React.useEffect(() => {
if (!provider) {
return;
}
if (isBindingInitialized.current) {
return;
}
isBindingInitialized.current = true;
const newBinding = yjs.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__*/jsxRuntime.jsx(jsxRuntime.Fragment, {});
}
return /*#__PURE__*/jsxRuntime.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;
}
exports.CollaborationPlugin = CollaborationPlugin;