UNPKG

@lexical/react

Version:

This package provides Lexical components and hooks for React applications.

740 lines (723 loc) • 27.2 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. * */ import { useCollaborationContext } from '@lexical/react/LexicalCollaborationContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { syncCursorPositions, syncLexicalUpdateToYjs, createUndoManager, setLocalStateFocus, createBindingV2__EXPERIMENTAL, CLEAR_DIFF_VERSIONS_COMMAND__EXPERIMENTAL, syncYjsStateToLexicalV2__EXPERIMENTAL, DIFF_VERSIONS_COMMAND__EXPERIMENTAL, renderSnapshot__EXPERIMENTAL, syncLexicalUpdateToYjsV2__EXPERIMENTAL, syncYjsChangesToLexical, initLocalState, TOGGLE_CONNECT_COMMAND, syncYjsChangesToLexicalV2__EXPERIMENTAL, removeCursorHighlightRule, CONNECTED_COMMAND, createYjsBinding } from '@lexical/yjs'; import * as React from 'react'; import { useRef, useCallback, useEffect, useMemo, useState } from 'react'; import { SKIP_COLLAB_TAG, mergeRegister, FOCUS_COMMAND, COMMAND_PRIORITY_EDITOR, BLUR_COMMAND, getActiveElement, registerEventListeners, UNDO_COMMAND, REDO_COMMAND, $getRoot, HISTORY_MERGE_TAG, $createParagraphNode, $getSelection, CAN_UNDO_COMMAND, CAN_REDO_COMMAND } 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. * */ /** * Well-known key under which the active Yjs {@link UndoManager} is published on * the editor instance (mirroring how `@lexical/extension` attaches its builder * via a `Symbol.for` key). Collab disables `@lexical/history`, so this is the * handle tooling and e2e tests use to force a deterministic undo boundary via * `editor[COLLAB_UNDO_MANAGER]?.stopCapturing()` instead of waiting out the * UndoManager capture timeout. */ const COLLAB_UNDO_MANAGER = Symbol.for('@lexical/yjs/UndoManager'); function useYjsCollaboration(editor, id, provider, docMap, name, color, shouldBootstrap, binding, setDoc, cursorsContainerRef, initialEditorState, awarenessData, syncCursorPositionsFn = syncCursorPositions, selectionHighlight = false) { const isReloadingDoc = useRef(false); const onBootstrap = useCallback(() => { const { root } = binding; if (shouldBootstrap && root.isEmpty() && root._xmlText._length === 0) { bootstrapEditor(binding, editor, initialEditorState); } }, [binding, editor, initialEditorState, shouldBootstrap]); useEffect(() => { const { root } = binding; const onYjsTreeChanges = (events, transaction) => { const origin = transaction.origin; if (origin !== binding) { const isFromUndoManger = origin instanceof UndoManager; 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(SKIP_COLLAB_TAG)) { 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). 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, selectionHighlight); return useYjsCursors(binding, cursorsContainerRef); } function useYjsCollaborationV2__EXPERIMENTAL(editor, id, doc, provider, docMap, name, color, options = {}) { const { awarenessData, excludedProperties, rootName, getXmlElement, selectionHighlight = false, __shouldBootstrapUnsafe: shouldBootstrap } = options; // Note: v2 does not support 'reload' event, which is not an actual Yjs event type. const isReloadingDoc = useMemo(() => ({ current: false }), []); // Built once for this mount, in state rather than a memo: `useMemo` is a // hint React may discard, and its inputs include values (an inline // `excludedProperties` map, an inline root resolver) whose identity changes // on every render. Rebuilding would re-run `getXmlElement` -- which the // caller may use to create shared types -- and hand the editor a binding on // another root, which the next local update would then overwrite with this // editor's current content. Remount to edit a different document. const [binding] = useState(() => createBindingV2__EXPERIMENTAL(editor, id, doc, docMap, { excludedProperties, getXmlElement, rootName })); useEffect(() => { docMap.set(id, doc); return () => { docMap.delete(id); }; }, [doc, docMap, id]); const onBootstrap = useCallback(() => { const { root } = binding; if (shouldBootstrap && root._length === 0) { bootstrapEditor(binding, editor); } }, [binding, editor, shouldBootstrap]); const [diffSnapshots, setDiffSnapshots] = useState(); useEffect(() => { mergeRegister(editor.registerCommand(CLEAR_DIFF_VERSIONS_COMMAND__EXPERIMENTAL, () => { setDiffSnapshots(null); // Ensure that any state already in Yjs is loaded into the editor (eg: after clearing diff view). syncYjsStateToLexicalV2__EXPERIMENTAL(binding, provider); return true; }, COMMAND_PRIORITY_EDITOR), editor.registerCommand(DIFF_VERSIONS_COMMAND__EXPERIMENTAL, ({ prevSnapshot, snapshot }) => { setDiffSnapshots({ prevSnapshot, snapshot }); return true; }, COMMAND_PRIORITY_EDITOR)); }, [editor, binding, provider]); useEffect(() => { const { root } = binding; if (diffSnapshots) { renderSnapshot__EXPERIMENTAL(binding, diffSnapshots.snapshot, diffSnapshots.prevSnapshot); return; } const onYjsTreeChanges = (events, transaction) => { const origin = transaction.origin; if (origin !== binding) { const isFromUndoManger = origin instanceof UndoManager; 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, dirtyLeaves, normalizedNodes, tags }) => { if (!tags.has(SKIP_COLLAB_TAG)) { syncLexicalUpdateToYjsV2__EXPERIMENTAL(binding, provider, prevEditorState, editorState, dirtyElements, dirtyLeaves, normalizedNodes, tags); } }); return () => { root.unobserveDeep(onYjsTreeChanges); removeListener(); }; }, [binding, provider, editor, diffSnapshots]); useProvider(editor, provider, name, color, isReloadingDoc, awarenessData, onBootstrap); useAwareness(binding, provider, selectionHighlight); return binding; } function useProvider(editor, provider, name, color, isReloadingDoc, awarenessData, onBootstrap) { const connect = useCallback(() => provider.connect(), [provider]); const disconnect = useCallback(() => { try { provider.disconnect(); } catch (_e) { // Do nothing } }, [provider]); useEffect(() => { const onStatus = ({ status }) => { editor.dispatchCommand(CONNECTED_COMMAND, status === 'connected'); }; const onSync = isSynced => { if (isSynced && isReloadingDoc.current === false && onBootstrap) { onBootstrap(); } }; const rootElement = editor.getRootElement(); initLocalState(provider, name, color, // getActiveElement rather than document.activeElement, which reports the // shadow host when the editor is in a shadow root. rootElement !== null && getActiveElement(rootElement) === rootElement, 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]); 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]); // 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 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) return registerEventListeners(window, { beforeunload: clearAwarenessState, pagehide: clearAwarenessState }); }, [provider]); } function useAwareness(binding, provider, selectionHighlight) { useEffect(() => { const { awareness } = provider; const onAwarenessUpdate = () => { syncCursorPositions(binding, provider, { selectionHighlight }); }; awareness.on('update', onAwarenessUpdate); return () => { awareness.off('update', onAwarenessUpdate); }; }, [binding, provider, selectionHighlight]); } function useYjsCursors(binding, cursorsContainerRef) { return useMemo(() => { const ref = element => { // eslint-disable-next-line react-hooks/immutability binding.cursorsContainer = element; }; return /*#__PURE__*/createPortal(/*#__PURE__*/jsx("div", { ref: ref }), // eslint-disable-next-line no-restricted-syntax cursorsContainerRef && cursorsContainerRef.current || document.body); }, [binding, cursorsContainerRef]); } 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]); return useYjsUndoManager(editor, undoManager); } function useYjsHistoryV2(editor, binding) { const undoManager = useMemo(() => createUndoManager(binding, binding.root), [binding]); return useYjsUndoManager(editor, undoManager); } function useYjsUndoManager(editor, undoManager) { 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)); }); // Publish the UndoManager on the editor (see COLLAB_UNDO_MANAGER) so tooling // and e2e tests can reach it; remove it again when it changes or unmounts. useEffect(() => { const withManager = editor; // eslint-disable-next-line react-hooks/immutability withManager[COLLAB_UNDO_MANAGER] = undoManager; return () => { if (withManager[COLLAB_UNDO_MANAGER] === undoManager) { delete withManager[COLLAB_UNDO_MANAGER]; } }; }, [editor, undoManager]); 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; } /** * Write the initial editor state into an empty shared document. The write is * flagged on the binding so that the Yjs UndoManager created by * `createUndoManager` skips the resulting transaction: bootstrapping is not a * user edit and must not be undoable, which matches a non-collab editor where * the initial state is applied with HISTORY_MERGE_TAG (#7110). */ function bootstrapEditor(binding, editor, initialEditorState) { binding.isBootstrapping = true; try { // The Yjs write happens in the update listener during the commit, which is // not necessarily synchronous with this call, so the flag has to outlive // it. An `onUpdate` callback is the boundary that matches the write: // Lexical queues it on `editor._deferred` before the update body runs and // flushes it at the tail of the same commit that ran the update listeners, // so it lands after the write and never before it. A queued deferred // callback also forces a commit on its own, so this still runs when the // update turns out to be a no-op. initializeEditor(editor, initialEditorState, () => { binding.isBootstrapping = false; }); } finally { // `onUpdate` alone is not enough: when the update body throws, Lexical // reports the error, commits (running the update listeners, so the Yjs // write still happens), and skips that commit's deferred callbacks. The // reset would then be left queued until the tail of the *next* commit, // by which point that commit's listener has already written to Yjs with // the flag set — silently keeping the user's first edit after a failed // bootstrap out of the undo stack. This bounds the flag's lifetime to a // microtask no matter how the update ends. It cannot fire early: the // commit is scheduled from inside `editor.update` above, so its microtask // is queued ahead of this one. queueMicrotask(() => { binding.isBootstrapping = false; }); } } function initializeEditor(editor, initialEditorState, onUpdate) { 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_TAG }); break; } case 'object': { editor.setEditorState(initialEditorState, { tag: HISTORY_MERGE_TAG }); break; } case 'function': { editor.update(() => { const root1 = $getRoot(); if (root1.isEmpty()) { initialEditorState(editor); } }, { tag: HISTORY_MERGE_TAG }); break; } } } else { const paragraph = $createParagraphNode(); root.append(paragraph); const rootElement = editor.getRootElement(); if ($getSelection() !== null || rootElement !== null && getActiveElement(rootElement) === rootElement) { paragraph.select(); } } } }, { onUpdate, tag: HISTORY_MERGE_TAG }); } function clearEditorSkipCollab(editor, binding) { // reset editor state editor.update(() => { const root = $getRoot(); root.clear(); root.select(); }, { tag: SKIP_COLLAB_TAG }); if (binding.cursors == null) { return; } const cursors = binding.cursors; if (cursors == null) { return; } const cursorsContainer = binding.cursorsContainer; if (cursorsContainer == null) { return; } for (const cursor of cursors.values()) { const selection = cursor.selection; if (selection === null) { continue; } if (selection.highlight !== null) { CSS.highlights.delete(selection.highlightName); removeCursorHighlightRule(binding, selection.highlightName); } if (selection.caret.parentNode === cursorsContainer) { cursorsContainer.removeChild(selection.caret); } for (const span of selection.selections) { if (span.parentNode === cursorsContainer) { cursorsContainer.removeChild(span); } } cursor.selection = null; } } /** * 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. * */ /** * Connects the editor to a Yjs document for real-time collaboration, syncing * editor state and rendering remote users' cursors and selections. Provide a * `providerFactory` that creates the Yjs {@link Provider} for the given * document `id`. Must be used within a {@link LexicalCollaboration} provider. * * @returns The element that renders collaborators' cursors (or an empty * fragment until the provider and binding are initialized). */ function CollaborationPlugin({ id, providerFactory, shouldBootstrap, username, cursorColor, cursorsContainerRef, initialEditorState, excludedProperties, awarenessData, syncCursorPositionsFn, selectionHighlight, rootName, getXmlText }) { const isBindingInitialized = useRef(false); // The inputs that produced the current Provider. A ref rather than the effect // deps alone because the effect must be idempotent: React StrictMode (and // React 18+ remounts in general) re-runs the effect with unchanged inputs and // must not create a second Provider. const providerInputs = useRef(null); const providerRef = useRef(null); const collabContext = useCollaborationContext(username, cursorColor); const { yjsDocMap, name, color } = collabContext; const [editor] = useLexicalComposerContext(); useCollabActive(collabContext, editor); const [provider, setProvider] = useState(); const [doc, setDoc] = useState(); useEffect(() => { const prevInputs = providerInputs.current; if (prevInputs !== null && prevInputs.id === id && prevInputs.providerFactory === providerFactory && prevInputs.yjsDocMap === yjsDocMap) { return; } providerInputs.current = { id, providerFactory, yjsDocMap }; const newProvider = providerFactory(id, yjsDocMap); const previousProvider = providerRef.current; // Disconnected here rather than from this effect's cleanup, and only when // something really did replace it. A `providerFactory` declared inline -- // the shape this package's own test harness uses -- has a fresh identity // every render, so a cleanup-based disconnect tears down the live provider // on every parent render; and when such a factory hands back a cached // provider, setProvider() bails on the identical value, nothing re-runs, // and the editor is left permanently disconnected. if (previousProvider !== null && previousProvider !== newProvider) { previousProvider.disconnect(); } providerRef.current = newProvider; setProvider(newProvider); setDoc(yjsDocMap.get(id)); }, [id, providerFactory, yjsDocMap]); useEffect(() => { return () => { const currentProvider = providerRef.current; if (currentProvider !== null) { providerRef.current = null; currentProvider.disconnect(); } }; }, []); const [binding, setBinding] = useState(); useEffect(() => { if (!provider) { return; } if (isBindingInitialized.current) { return; } const resolvedDoc = doc || yjsDocMap.get(id); if (!resolvedDoc) { return; } isBindingInitialized.current = true; const newBinding = createYjsBinding({ doc: resolvedDoc, docMap: yjsDocMap, editor, excludedProperties, getXmlText, id, rootName }); // eslint-disable-next-line react-hooks/set-state-in-effect setBinding(newBinding); // `excludedProperties`, `rootName` and `getXmlText` configure the binding // and are read on the pass that creates it (which is not necessarily the // first one -- the effect returns early until the provider exists, so a // root that only resolves after mount is still picked up). They are // deliberately not dependencies: a binding cannot be reconfigured or // repointed once it exists, and re-running this effect would only tear the // editor's binding down. // eslint-disable-next-line react-hooks/exhaustive-deps }, [editor, provider, id, yjsDocMap, doc]); // Destroying the binding belongs to unmount, not to this effect's cleanup: // the binding is created once, so a cleanup on the creation effect would // destroy it on any input change without building a replacement. useEffect(() => { if (binding === undefined) { return; } return () => { binding.root.destroy(binding); }; }, [binding]); 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, syncCursorPositionsFn: syncCursorPositionsFn, selectionHighlight: selectionHighlight }); } function YjsCollaborationCursors({ editor, id, provider, yjsDocMap, name, color, shouldBootstrap, cursorsContainerRef, initialEditorState, awarenessData, collabContext, binding, setDoc, syncCursorPositionsFn, selectionHighlight }) { const cursors = useYjsCollaboration(editor, id, provider, yjsDocMap, name, color, shouldBootstrap, binding, setDoc, cursorsContainerRef, initialEditorState, awarenessData, syncCursorPositionsFn, selectionHighlight); useYjsHistory(editor, binding); useYjsFocusTracking(editor, provider, name, color, awarenessData); return cursors; } /** * A variant of {@link CollaborationPlugin} that takes an already-created Yjs * `doc` and {@link Provider} directly instead of a provider factory, giving the * application full control over their lifecycle. Must be used within a * {@link LexicalCollaboration} provider. * * @experimental The API may change in a future release. * @returns The element that renders collaborators' cursors. */ function CollaborationPluginV2__EXPERIMENTAL({ id, doc, provider, __shouldBootstrapUnsafe, username, cursorColor, cursorsContainerRef, excludedProperties, awarenessData, selectionHighlight, rootName, getXmlElement }) { const collabContext = useCollaborationContext(username, cursorColor); const { yjsDocMap, name, color } = collabContext; const [editor] = useLexicalComposerContext(); useCollabActive(collabContext, editor); const binding = useYjsCollaborationV2__EXPERIMENTAL(editor, id, doc, provider, yjsDocMap, name, color, { __shouldBootstrapUnsafe, awarenessData, excludedProperties, getXmlElement, rootName, selectionHighlight }); useYjsHistoryV2(editor, binding); useYjsFocusTracking(editor, provider, name, color, awarenessData); return useYjsCursors(binding, cursorsContainerRef); } const useCollabActive = (collabContext, editor) => { useEffect(() => { // eslint-disable-next-line react-hooks/immutability 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]); }; export { CollaborationPlugin, CollaborationPluginV2__EXPERIMENTAL };