UNPKG

@lexical/react

Version:

This package provides Lexical components and hooks for React applications.

585 lines (567 loc) 19.7 kB
/** * 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. * */ 'use strict'; 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 onBootstrap = React.useCallback(() => { const { root } = binding; if (shouldBootstrap && root.isEmpty() && root._xmlText._length === 0) { initializeEditor(editor, initialEditorState); } }, [binding, editor, initialEditorState, shouldBootstrap]); React.useEffect(() => { const { root } = binding; 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); } }; // 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(lexical.SKIP_COLLAB_TAG)) { yjs.syncLexicalUpdateToYjs(binding, provider, prevEditorState, editorState, dirtyElements, dirtyLeaves, normalizedNodes, tags); } }); return () => { root.getSharedType().unobserveDeep(onYjsTreeChanges); removeListener(); }; }, [binding, provider, editor, setDoc, docMap, id, syncCursorPositionsFn]); // Note: 'reload' is not an actual Yjs event type. Included here for legacy support (#1409). React.useEffect(() => { const onProviderDocReload = ydoc => { clearEditorSkipCollab(editor, binding); setDoc(ydoc); docMap.set(id, ydoc); isReloadingDoc.current = true; }; const onSync = () => { isReloadingDoc.current = false; }; provider.on('reload', onProviderDocReload); provider.on('sync', onSync); return () => { provider.off('reload', onProviderDocReload); provider.off('sync', onSync); }; }, [binding, provider, editor, setDoc, docMap, id]); useProvider(editor, provider, name, color, isReloadingDoc, awarenessData, onBootstrap); useAwareness(binding, provider); return useYjsCursors(binding, cursorsContainerRef); } function useYjsCollaborationV2__EXPERIMENTAL(editor, id, doc, provider, docMap, name, color, options = {}) { const { awarenessData, excludedProperties, rootName, __shouldBootstrapUnsafe: shouldBootstrap } = options; // Note: v2 does not support 'reload' event, which is not an actual Yjs event type. const isReloadingDoc = React.useMemo(() => ({ current: false }), []); const binding = React.useMemo(() => yjs.createBindingV2__EXPERIMENTAL(editor, id, doc, docMap, { excludedProperties, rootName }), [editor, id, doc, docMap, excludedProperties, rootName]); React.useEffect(() => { docMap.set(id, doc); return () => { docMap.delete(id); }; }, [doc, docMap, id]); const onBootstrap = React.useCallback(() => { const { root } = binding; if (shouldBootstrap && root._length === 0) { initializeEditor(editor); } }, [binding, editor, shouldBootstrap]); const [diffSnapshots, setDiffSnapshots] = React.useState(); React.useEffect(() => { utils.mergeRegister(editor.registerCommand(yjs.CLEAR_DIFF_VERSIONS_COMMAND__EXPERIMENTAL, () => { setDiffSnapshots(null); // Ensure that any state already in Yjs is loaded into the editor (eg: after clearing diff view). yjs.syncYjsStateToLexicalV2__EXPERIMENTAL(binding, provider); return true; }, lexical.COMMAND_PRIORITY_EDITOR), editor.registerCommand(yjs.DIFF_VERSIONS_COMMAND__EXPERIMENTAL, ({ prevSnapshot, snapshot }) => { setDiffSnapshots({ prevSnapshot, snapshot }); return true; }, lexical.COMMAND_PRIORITY_EDITOR)); }, [editor, binding, provider]); React.useEffect(() => { const { root } = binding; if (diffSnapshots) { yjs.renderSnapshot__EXPERIMENTAL(binding, diffSnapshots.snapshot, diffSnapshots.prevSnapshot); return; } const onYjsTreeChanges = (events, transaction) => { const origin = transaction.origin; if (origin !== binding) { const isFromUndoManger = origin instanceof yjs$1.UndoManager; yjs.syncYjsChangesToLexicalV2__EXPERIMENTAL(binding, provider, events, transaction, isFromUndoManger); } }; // This updates the local editor state when we receive updates from other clients root.observeDeep(onYjsTreeChanges); const removeListener = editor.registerUpdateListener(({ prevEditorState, editorState, dirtyElements, normalizedNodes, tags }) => { if (!tags.has(lexical.SKIP_COLLAB_TAG)) { yjs.syncLexicalUpdateToYjsV2__EXPERIMENTAL(binding, provider, prevEditorState, editorState, dirtyElements, normalizedNodes, tags); } }); return () => { root.unobserveDeep(onYjsTreeChanges); removeListener(); }; }, [binding, provider, editor, diffSnapshots]); useProvider(editor, provider, name, color, isReloadingDoc, awarenessData, onBootstrap); useAwareness(binding, provider); return binding; } function useProvider(editor, provider, name, color, isReloadingDoc, awarenessData, onBootstrap) { const connect = React.useCallback(() => provider.connect(), [provider]); const disconnect = React.useCallback(() => { try { provider.disconnect(); } catch (_e) { // Do nothing } }, [provider]); React.useEffect(() => { const onStatus = ({ status }) => { editor.dispatchCommand(yjs.CONNECTED_COMMAND, status === 'connected'); }; const onSync = isSynced => { if (isSynced && isReloadingDoc.current === false && onBootstrap) { onBootstrap(); } }; yjs.initLocalState(provider, name, color, document.activeElement === editor.getRootElement(), awarenessData || {}); provider.on('status', onStatus); provider.on('sync', onSync); const connectionPromise = connect(); return () => { // eslint-disable-next-line react-hooks/exhaustive-deps -- expected that isReloadingDoc.current may change 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); }; }, [editor, provider, name, color, isReloadingDoc, awarenessData, onBootstrap, connect, disconnect]); 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]); // Clear awareness state immediately when tab is refreshed or closed // This prevents ghost cursors from appearing for several seconds after disconnect // See: https://github.com/facebook/lexical/issues/8061 React.useEffect(() => { const clearAwarenessState = () => { // Immediately clear local awareness state to signal disconnection // This broadcasts to other clients that this client has disconnected, // causing them to remove the cursor immediately instead of waiting for timeout try { provider.awareness.setLocalState(null); } catch (_e) { // Ignore errors during cleanup (e.g., if provider is already disconnected) } }; // Use both beforeunload and pagehide for maximum browser compatibility // beforeunload: fires before page unloads (may be cancelable) // pagehide: fires when page is being unloaded (more reliable, especially on mobile) window.addEventListener('beforeunload', clearAwarenessState); window.addEventListener('pagehide', clearAwarenessState); return () => { window.removeEventListener('beforeunload', clearAwarenessState); window.removeEventListener('pagehide', clearAwarenessState); }; }, [provider]); } function useAwareness(binding, provider) { React.useEffect(() => { const { awareness } = provider; const onAwarenessUpdate = () => { yjs.syncCursorPositions(binding, provider); }; awareness.on('update', onAwarenessUpdate); return () => { awareness.off('update', onAwarenessUpdate); }; }, [binding, provider]); } function useYjsCursors(binding, cursorsContainerRef) { return 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]); } 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]); return useYjsUndoManager(editor, undoManager); } function useYjsHistoryV2(editor, binding) { const undoManager = React.useMemo(() => yjs.createUndoManager(binding, binding.root), [binding]); return useYjsUndoManager(editor, undoManager); } function useYjsUndoManager(editor, undoManager) { 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: lexical.HISTORY_MERGE_TAG }); break; } case 'object': { editor.setEditorState(initialEditorState, { tag: lexical.HISTORY_MERGE_TAG }); break; } case 'function': { editor.update(() => { const root1 = lexical.$getRoot(); if (root1.isEmpty()) { initialEditorState(editor); } }, { tag: lexical.HISTORY_MERGE_TAG }); break; } } } else { const paragraph = lexical.$createParagraphNode(); root.append(paragraph); const { activeElement } = document; if (lexical.$getSelection() !== null || activeElement !== null && activeElement === editor.getRootElement()) { paragraph.select(); } } } }, { tag: lexical.HISTORY_MERGE_TAG }); } function clearEditorSkipCollab(editor, binding) { // reset editor state editor.update(() => { const root = lexical.$getRoot(); root.clear(); root.select(); }, { tag: lexical.SKIP_COLLAB_TAG }); 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, syncCursorPositionsFn }) { 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(); useCollabActive(collabContext, editor); const [provider, setProvider] = React.useState(); const [doc, setDoc] = React.useState(); React.useEffect(() => { if (isProviderInitialized.current) { return; } isProviderInitialized.current = true; const newProvider = providerFactory(id, yjsDocMap); setProvider(newProvider); setDoc(yjsDocMap.get(id)); return () => { newProvider.disconnect(); }; }, [id, providerFactory, yjsDocMap]); 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, syncCursorPositionsFn: syncCursorPositionsFn }); } function YjsCollaborationCursors({ editor, id, provider, yjsDocMap, name, color, shouldBootstrap, cursorsContainerRef, initialEditorState, awarenessData, collabContext, binding, setDoc, syncCursorPositionsFn }) { const cursors = useYjsCollaboration(editor, id, provider, yjsDocMap, name, color, shouldBootstrap, binding, setDoc, cursorsContainerRef, initialEditorState, awarenessData, syncCursorPositionsFn); useYjsHistory(editor, binding); useYjsFocusTracking(editor, provider, name, color, awarenessData); return cursors; } function CollaborationPluginV2__EXPERIMENTAL({ id, doc, provider, __shouldBootstrapUnsafe, username, cursorColor, cursorsContainerRef, excludedProperties, awarenessData }) { const collabContext = LexicalCollaborationContext.useCollaborationContext(username, cursorColor); const { yjsDocMap, name, color } = collabContext; const [editor] = LexicalComposerContext.useLexicalComposerContext(); useCollabActive(collabContext, editor); const binding = useYjsCollaborationV2__EXPERIMENTAL(editor, id, doc, provider, yjsDocMap, name, color, { __shouldBootstrapUnsafe, awarenessData, excludedProperties }); useYjsHistoryV2(editor, binding); useYjsFocusTracking(editor, provider, name, color, awarenessData); return useYjsCursors(binding, cursorsContainerRef); } const useCollabActive = (collabContext, editor) => { 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]); }; exports.CollaborationPlugin = CollaborationPlugin; exports.CollaborationPluginV2__EXPERIMENTAL = CollaborationPluginV2__EXPERIMENTAL;