facebook / lexical

Lexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.
https://lexical.dev
MIT License
19.39k stars 1.64k forks source link

How to call `getEditorState`/`setEditorState` on a `LexicalComposer` instance and how to claw this instance from React? #5473

Open vadimkantorov opened 8 months ago

vadimkantorov commented 8 months ago

I'm building a single-user, rich-text editor using playground as a starting point. My editor would be used in a React-less / vanilla JavaScript callee script. I'm not a front-end dev (in most likelihood, I'm hitting some react-beginner mistakes), so I'm lacking basic React understanding, but I almost managed to create a custom React-based lexical-based editor control.

I created two files with source below. In my final consuming file I have (modulo copying the assets dir). Currently, the editor loads okay, but I don't see getEditorState on the window.editor and window.LexicalMarkdownEditor_getEditor() throws some cryptic error (which I'm not able to decrypt because I managed to build only vite.prod.config.js) which might mean that I cannot use useLexicalComposerContext as a free function).

How can I get an instance with methods like getEditorState/setEditorState out of the app instance (i.e. get notified when the component was mounted and get a component instance back)?

Thank you very much!

Error:

Uncaught TypeError: Cannot read properties of null (reading 'useContext')
    at l.useContext (main.js:1:7492)
    at Ju.useLexicalComposerContext (main.js:21:129559)
    at window.LexicalMarkdownEditor_getEditor (main.js:21:1281146)
    at on_editor_script_loaded ((index):13:32)
    at HTMLScriptElement.onload ((index):4:100)
l.useContext @ main.js:1
Ju.useLexicalComposerContext @ main.js:21
window.LexicalMarkdownEditor_getEditor @ main.js:21
on_editor_script_loaded @ (index):13
onload @ (index):4
<!-- ./index.html -->
<html>
    <head>
        <link rel="stylesheet" href="/assets/main.css">
        <script type="module" crossorigin src="/assets/main.js" onload="on_editor_script_loaded()"></script>
    </head>
    <body>
        <div id="editor"></div>
        <script>
        function on_editor_script_loaded()
        {
            window.editor = window.LexicalMarkdownEditor('#editor');
            console.log(window.editor);
            console.log(window.LexicalMarkdownEditor_getEditor());

            // want to do window.editor.setEditorState or window.editor.getEditorState, similarly to quill
            // or to be able to set editor state from some markdown string, or get a markdown string back
        }
        </script>
    </body>
</html>
// ./packages/lexical-playground/src/indexEditorOnly.tsx

/**
 * 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 './setupEnv';
import './index.css';

import * as React from 'react';
import {createRoot} from 'react-dom/client';

import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {LexicalComposer} from '@lexical/react/LexicalComposer';

import EditorOnly from './EditorOnly';
import PlaygroundNodes from './nodes/PlaygroundNodes';
import {TableContext} from './plugins/TablePlugin';
import PlaygroundEditorTheme from './themes/PlaygroundEditorTheme';

// Handle runtime errors
const showErrorOverlay = (err: Event) => {
  const ErrorOverlay = customElements.get('vite-error-overlay');
  if (!ErrorOverlay) {
    return;
  }
  const overlay = new ErrorOverlay(err);
  const body = document.body;
  if (body !== null) {
    body.appendChild(overlay);
  }
};

window.addEventListener('error', showErrorOverlay);
window.addEventListener('unhandledrejection', ({reason}) =>
  showErrorOverlay(reason),
);

function AppEditorOnly(): JSX.Element {
  const initialConfig = {
    editorState: null, // undefined?
    namespace: 'Playground',
    nodes: [...PlaygroundNodes],
    onError: (error: Error) => {
      throw error;
    },
    theme: PlaygroundEditorTheme,
  };

  return (
    <LexicalComposer initialConfig={initialConfig}>
        <TableContext>
            <div className="editor-shell">
              <EditorOnly showTreeView={false} showActions={false} />
            </div>
        </TableContext>
    </LexicalComposer>
  );
}

window.LexicalMarkdownEditor = query_selector =>
{
    const root = createRoot(document.querySelector(query_selector) as HTMLElement);
    const app = React.createElement(AppEditorOnly);
    root.render(app);
    return app;
}

window.LexicalMarkdownEditor_getEditor = () =>
{
    const [editor] = useLexicalComposerContext();
    return editor;
}

and

// ./packages/lexical-playground/src/EditorOnly.tsx

/**
 * 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 {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin';
import {CharacterLimitPlugin} from '@lexical/react/LexicalCharacterLimitPlugin';
import {CheckListPlugin} from '@lexical/react/LexicalCheckListPlugin';
import {ClearEditorPlugin} from '@lexical/react/LexicalClearEditorPlugin';
import LexicalClickableLinkPlugin from '@lexical/react/LexicalClickableLinkPlugin';
import {CollaborationPlugin} from '@lexical/react/LexicalCollaborationPlugin';
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';
import {HashtagPlugin} from '@lexical/react/LexicalHashtagPlugin';
import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
import {HorizontalRulePlugin} from '@lexical/react/LexicalHorizontalRulePlugin';
import {ListPlugin} from '@lexical/react/LexicalListPlugin';
import {PlainTextPlugin} from '@lexical/react/LexicalPlainTextPlugin';
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
import {TabIndentationPlugin} from '@lexical/react/LexicalTabIndentationPlugin';
import {TablePlugin} from '@lexical/react/LexicalTablePlugin';
import useLexicalEditable from '@lexical/react/useLexicalEditable';
import * as React from 'react';
import {useEffect, useState} from 'react';
import {CAN_USE_DOM} from 'shared/canUseDOM';

import {createWebsocketProvider} from './collaboration';
import {useSettings} from './context/SettingsContext';
import {useSharedHistoryContext} from './context/SharedHistoryContext';
import ActionsPlugin from './plugins/ActionsPlugin';
import AutocompletePlugin from './plugins/AutocompletePlugin';
import AutoEmbedPlugin from './plugins/AutoEmbedPlugin';
import AutoLinkPlugin from './plugins/AutoLinkPlugin';
import CodeActionMenuPlugin from './plugins/CodeActionMenuPlugin';
import CodeHighlightPlugin from './plugins/CodeHighlightPlugin';
import CollapsiblePlugin from './plugins/CollapsiblePlugin';
import ComponentPickerPlugin from './plugins/ComponentPickerPlugin';
import ContextMenuPlugin from './plugins/ContextMenuPlugin';
import DragDropPaste from './plugins/DragDropPastePlugin';
import DraggableBlockPlugin from './plugins/DraggableBlockPlugin';
import EmojiPickerPlugin from './plugins/EmojiPickerPlugin';
import EmojisPlugin from './plugins/EmojisPlugin';
import EquationsPlugin from './plugins/EquationsPlugin';
import ExcalidrawPlugin from './plugins/ExcalidrawPlugin';
import FigmaPlugin from './plugins/FigmaPlugin';
import FloatingLinkEditorPlugin from './plugins/FloatingLinkEditorPlugin';
import FloatingTextFormatToolbarPlugin from './plugins/FloatingTextFormatToolbarPlugin';
import ImagesPlugin from './plugins/ImagesPlugin';
import InlineImagePlugin from './plugins/InlineImagePlugin';
import KeywordsPlugin from './plugins/KeywordsPlugin';
import {LayoutPlugin} from './plugins/LayoutPlugin/LayoutPlugin';
import LinkPlugin from './plugins/LinkPlugin';
import ListMaxIndentLevelPlugin from './plugins/ListMaxIndentLevelPlugin';
import MarkdownShortcutPlugin from './plugins/MarkdownShortcutPlugin';
import {MaxLengthPlugin} from './plugins/MaxLengthPlugin';
import MentionsPlugin from './plugins/MentionsPlugin';
import PageBreakPlugin from './plugins/PageBreakPlugin';
import PollPlugin from './plugins/PollPlugin';
import SpeechToTextPlugin from './plugins/SpeechToTextPlugin';
import TabFocusPlugin from './plugins/TabFocusPlugin';
import TableCellActionMenuPlugin from './plugins/TableActionMenuPlugin';
import TableCellResizer from './plugins/TableCellResizer';
import TableOfContentsPlugin from './plugins/TableOfContentsPlugin';
import ToolbarPlugin from './plugins/ToolbarPlugin';
import TreeViewPlugin from './plugins/TreeViewPlugin';
import TwitterPlugin from './plugins/TwitterPlugin';
import YouTubePlugin from './plugins/YouTubePlugin';
import ContentEditable from './ui/ContentEditable';
import Placeholder from './ui/Placeholder';

export default function EditorOnly({
  showTreeView,
  showActions,
}: {
  showTreeView: boolean;
  showActions: boolean;
}): JSX.Element {
  const {historyState} = useSharedHistoryContext();
  const {
    settings: {
      isAutocomplete,
      isMaxLength,
      isCharLimit,
      isCharLimitUtf8,
      showTableOfContents,
      shouldUseLexicalContextMenu,
      tableCellMerge,
      tableCellBackgroundColor,
    },
  } = useSettings();
  const isEditable = useLexicalEditable();
  const placeholder = <Placeholder>Enter some rich text...</Placeholder>;
  const [floatingAnchorElem, setFloatingAnchorElem] =
    useState<HTMLDivElement | null>(null);
  const [isSmallWidthViewport, setIsSmallWidthViewport] =
    useState<boolean>(false);
  const [isLinkEditMode, setIsLinkEditMode] = useState<boolean>(false);

  const onRef = (_floatingAnchorElem: HTMLDivElement) => {
    if (_floatingAnchorElem !== null) {
      setFloatingAnchorElem(_floatingAnchorElem);
    }
  };

  useEffect(() => {
    const updateViewPortWidth = () => {
      const isNextSmallWidthViewport =
        CAN_USE_DOM && window.matchMedia('(max-width: 1025px)').matches;

      if (isNextSmallWidthViewport !== isSmallWidthViewport) {
        setIsSmallWidthViewport(isNextSmallWidthViewport);
      }
    };
    updateViewPortWidth();
    window.addEventListener('resize', updateViewPortWidth);

    return () => {
      window.removeEventListener('resize', updateViewPortWidth);
    };
  }, [isSmallWidthViewport]);

  return (
    <>
      <ToolbarPlugin setIsLinkEditMode={setIsLinkEditMode} />
      <div
        className={`editor-container ${showTreeView ? 'tree-view' : ''}
        `}>
        {isMaxLength && <MaxLengthPlugin maxLength={30} />}
        <DragDropPaste />
        <AutoFocusPlugin />
        <ClearEditorPlugin />
        <ComponentPickerPlugin />
        <EmojiPickerPlugin />
        <AutoEmbedPlugin />

        <MentionsPlugin />
        <EmojisPlugin />
        <HashtagPlugin />
        <KeywordsPlugin />
        <SpeechToTextPlugin />
        <AutoLinkPlugin />

        <HistoryPlugin externalHistoryState={historyState} />
        <RichTextPlugin
          contentEditable={
            <div className="editor-scroller">
              <div className="editor" ref={onRef}>
                <ContentEditable />
              </div>
            </div>
          }
          placeholder={placeholder}
          ErrorBoundary={LexicalErrorBoundary}
        />
        <MarkdownShortcutPlugin />
        <CodeHighlightPlugin />
        <ListPlugin />
        <CheckListPlugin />
        <ListMaxIndentLevelPlugin maxDepth={7} />
        <TablePlugin
          hasCellMerge={tableCellMerge}
          hasCellBackgroundColor={tableCellBackgroundColor}
        />
        <TableCellResizer />
        <ImagesPlugin />
        <InlineImagePlugin />
        <LinkPlugin />
        <PollPlugin />
        <TwitterPlugin />
        <YouTubePlugin />
        <FigmaPlugin />
        {!isEditable && <LexicalClickableLinkPlugin />}
        <HorizontalRulePlugin />
        <EquationsPlugin />
        <ExcalidrawPlugin />
        <TabFocusPlugin />
        <TabIndentationPlugin />
        <CollapsiblePlugin />
        <PageBreakPlugin />
        <LayoutPlugin />
        {floatingAnchorElem && !isSmallWidthViewport && (
          <>
            <DraggableBlockPlugin anchorElem={floatingAnchorElem} />
            <CodeActionMenuPlugin anchorElem={floatingAnchorElem} />
            <FloatingLinkEditorPlugin
              anchorElem={floatingAnchorElem}
              isLinkEditMode={isLinkEditMode}
              setIsLinkEditMode={setIsLinkEditMode}
            />
            <TableCellActionMenuPlugin
              anchorElem={floatingAnchorElem}
              cellMerge={true}
            />
            <FloatingTextFormatToolbarPlugin
              anchorElem={floatingAnchorElem}
            />
          </>
        )}

        {(isCharLimit || isCharLimitUtf8) && (
          <CharacterLimitPlugin
            charset={isCharLimit ? 'UTF-16' : 'UTF-8'}
            maxLength={5}
          />
        )}
        {isAutocomplete && <AutocompletePlugin />}
        <div>{showTableOfContents && <TableOfContentsPlugin />}</div>
        {shouldUseLexicalContextMenu && <ContextMenuPlugin />}
        {showActions && <ActionsPlugin isRichText={true} />}
      </div>
      {showTreeView && <TreeViewPlugin />}
    </>
  );
}
vadimkantorov commented 8 months ago

I hoped I could get it working with:

import * as React from 'react';

window.LexicalMarkdownEditor = query_selector =>
{
    const htmlElem = document.querySelector(query_selector) as HTMLElement;
    const root = createRoot(htmlElem);

    const initialConfig = {
        editorState: null, // undefined?
        //editorState: () => {
        //  $convertFromMarkdownString(markdown, TRANSFORMERS);
        //},
        namespace: 'Playground',
        nodes: [...PlaygroundNodes],
        onError: (error: Error) => {
            throw error;
        },
        theme: PlaygroundEditorTheme,
    };

    // fails with TypeError: Cannot read properties of null (reading 'useRef')
    //const myRef = React.useRef(null);

    const app = (
      <LexicalComposer initialConfig={initialConfig}>
          <TableContext>
              <div className="editor-shell">
                <EditorOnly showTreeView={false} showActions={false} />
              </div>
          </TableContext>
      </LexicalComposer>
    );

    // fails with TypeError: Cannot read properties of null (reading 'useEffect')
    //React.useEffect(() => console.log('window.LexicalMarkdownEditor2', myRef));

    console.log('window.LexicalMarkdownEditor1', app)
    const res = root.render(app);
    console.log('window.LexicalMarkdownEditor3', res)

    return app;
}

but both useRef (to get an instance to the rendered JSX component instance) and useEffect (to get a notification when the component is mounted and ready) fail with an extremely strange error which I cannot resolve (despite both useRef/useEffect being employed in other tsx files without issues)...

I've tried using createRef instead of useRef:

    window.myRef = React.createRef(null);

    const res = root.render(
        <LexicalComposer initialConfig={initialConfig} ref={window.myRef}>
            <TableContext>
                <div className="editor-shell">
                  <EditorOnly showTreeView={false} showActions={false} />
                </div>
            </TableContext>
        </LexicalComposer>
    );

This does not error out, but window.myRef.current is always null.

I realized that the component instance might not be ready right away. Is it true?

I've also tried adding ref={(myRef) => console.log('window.LexicalMarkdownEditor2', myRef)}, but it's not triggered.

How can I get a notification when the editor is ready (for calling instance methods like setEditorState/getEditorState) and get its instance?

Thank you!

vadimkantorov commented 8 months ago

I've also tried an alternative route of using class component. This prints both window.LexicalMarkdownEditor3 and window.LexicalMarkdownEditor4 (as opposed to my other attempts using function components), but none of them have access to the instance of LexicalComposer :( Still looking for a way to get hands on this instance :)

class AppEditorOnly extends React.Component
{
    componentDidMount()
    {
       console.log('window.LexicalMarkdownEditor3', this);
    }

    render()
    {
        return <LexicalComposer initialConfig={this.props.initialConfig} ref={(myRef) => console.log('window.LexicalMarkdownEditor4', myRef)}>
            <TableContext>
                <div className="editor-shell">
                  <EditorOnly showTreeView={false} showActions={false} />
                </div>
            </TableContext>
        </LexicalComposer>
    }
}
window.LexicalMarkdownEditor = query_selector =>
{
    const root = createRoot(document.querySelector(query_selector) as HTMLElement);

    const initialConfig = {
        editorState: null, // undefined?
        //editorState: () => {
        //  $convertFromMarkdownString(markdown, TRANSFORMERS);
        //},
        namespace: 'Playground',
        nodes: [...PlaygroundNodes],
        onError: (error: Error) => {
            throw error;
        },
        theme: PlaygroundEditorTheme,
    };

    const res = root.render(
        <AppEditorOnly initialConfig={initialConfig} />
    );

    return null;
}

Replacing it with the code below doesn't print anything useful either (when componentDidMount triggers, this.myRef contains only {current: null}):

class AppEditorOnly extends React.Component
{
    constructor()
    {
        super();
        this.myRef = React.createRef();
    }

    componentDidMount()
    {
       console.log('window.LexicalMarkdownEditor3', this.myRef);
    }

    render()
    {
        return <LexicalComposer initialConfig={this.props.initialConfig} ref={this.myRef}>
            <TableContext>
                <div className="editor-shell">
                    <EditorOnly showTreeView={false} showActions={false} />
                </div>
            </TableContext>
        </LexicalComposer>
    }
}
vadimkantorov commented 8 months ago

I found a workaround, but this is obviously a crazy hack, and there must be a proper solution (via refs? via class-based component? via createEffect?). I hope that React-savvy people can easily spot what I was doing wrong! It would be great to have a proper way of getting notified of a the readiness of an editor instance and get this instance as output (i.e. the promise-based return value, of the kind I'm using below)

window.LexicalMarkdownEditor = query_selector =>
{
    const root = createRoot(document.querySelector(query_selector) as HTMLElement);

    const initialConfig = {
        editorState: null, // undefined?
        //editorState: () => {
        //  $convertFromMarkdownString(markdown, TRANSFORMERS);
        //},
        namespace: 'Playground',
        nodes: [...PlaygroundNodes],
        onError: (error: Error) => {
            throw error;
        },
        theme: PlaygroundEditorTheme,
    };

    let resolveEditor = null;
    const resultPromise = new Promise((resolve, reject) => {resolveEditor = resolve});
    const GetLexicalComposerContext = () =>
    {
        const [editor] = useLexicalComposerContext();
        resolveEditor(editor);
        return null;
    }

    root.render(
        <LexicalComposer initialConfig={initialConfig}>
            <TableContext>
                <div className="editor-shell">
                  <EditorOnly showTreeView={false} showActions={false} />
                </div>
            </TableContext>
            <GetLexicalComposerContext />
        </LexicalComposer>
    );

    return resultPromise;
}
vadimkantorov commented 8 months ago

Should I try instead https://lexical.dev/docs/react/plugins#lexicaleditorrefplugin in place of GetLexicalComposerContext?