remirror/remirror

View on GitHub
packages/remirror__extension-react-component/src/react-component-extension.ts

Summary

Maintainability
A
0 mins
Test Coverage
A
92%
import type { ComponentType } from 'react';
import {
  AnyExtension,
  entries,
  extension,
  isNodeExtension,
  NodeViewMethod,
  object,
  PlainExtension,
} from '@remirror/core';

import type {
  NodeViewComponentProps,
  ReactComponentEnvironment,
  ReactComponentOptions,
} from './node-view-types';
import { PortalContainer } from './portals';
import { ReactNodeView } from './react-node-view';

/**
 * The extension transforms the `ReactComponent` property on extensions into the
 * following:
 *
 * - a valid `NodeView` wrapped dom element
 * - a valid `SSR` component.
 *
 * Currently this only support nodes. Support will be added for marks later.
 *
 * @remarks
 *
 * When creating a NodeView using the component property the `toDOM` method
 * returned by the `createNodeSpec` methods needs to be in the following format.
 *
 * - `string` - e.g. `div`. This will be used as the wrapper tag name. .
 * - `[string, 0]` - The wrapper tag name and a `0` indicating that this will be
 *   accepting content.
 * - `[string, object, 0?]` -The wrapper tag name, an object of the attributes
 *   that should be applied to the wrapper tag and a 0 when you want the react
 *   component to have content inserted into it.
 *
 * Unfortunately `React Components` currently require a wrapping tag element
 * when being used in the DOM. See the following for the reasons.
 *
 * ### Caveats
 *
 * It's not possible to create a node view without nested dom element in `react`
 * due to this issue https://github.com/facebook/react/issues/12227. It's
 * unlikely that this limitation will be changed any time soon
 * https://github.com/ProseMirror/prosemirror/issues/803
 *
 * NodeViews have a `dom` node which is used as the main wrapper element. For
 * paragraphs this would be the `p` tag and for text this is a `TEXT` node.
 * NodeView's  also have a `contentDOM` property which is where any content from
 * ProseMirror is injected.
 *
 * The difficulty in integration is that the dom node and the content dom node
 * of the `NodeView` are consumed synchronously by ProseMirror. However, react
 * requires a ref to capture the dom node which corresponds to the mounted
 * component. This is done asynchronously. As a result it's not possible to
 * provide the `dom` node or `contentDOM` to ProseMirror while using react.
 *
 * The only way around this is to create both the top level `dom` element and
 * the `contentDOM` element manually in the NodeView and provide a `forwardRef`
 * prop to the component. This prop must be attached to the part of the tree
 * where content should be rendered to. Once the React ref is available the
 * `forwardRef` prop appends the `contentDOM` to the element where `forwardRef`
 * was attached.
 */
@extension<ReactComponentOptions>({
  defaultOptions: {
    defaultBlockNode: 'div',
    defaultInlineNode: 'span',
    defaultContentNode: 'span',
    defaultEnvironment: 'both',
    nodeViewComponents: {},
    stopEvent: null,
  },
  staticKeys: ['defaultBlockNode', 'defaultInlineNode', 'defaultContentNode', 'defaultEnvironment'],
})
export class ReactComponentExtension extends PlainExtension<ReactComponentOptions> {
  /**
   * The portal container which keeps track of all the React Portals containing
   * custom prosemirror NodeViews.
   */
  private readonly portalContainer: PortalContainer = new PortalContainer();

  get name() {
    return 'reactComponent' as const;
  }

  /**
   * Add the portal container to the manager store. This can be used by the
   * `<Remirror />` component to manage portals for node content.
   */
  onCreate(): void {
    this.store.setStoreKey('portalContainer', this.portalContainer);
  }

  /**
   * Create the node views from the custom components provided.
   */
  createNodeViews(): Record<string, NodeViewMethod> {
    const nodeViews: Record<string, NodeViewMethod> = object();
    const managerComponents = this.store.managerSettings.nodeViewComponents ?? {};

    // Loop through the extension to pick out the ones with custom components.
    for (const extension of this.store.extensions) {
      if (
        !extension.ReactComponent ||
        !isNodeExtension(extension) ||
        extension.reactComponentEnvironment === 'ssr'
      ) {
        continue;
      }

      nodeViews[extension.name] = ReactNodeView.create({
        options: this.options,
        ReactComponent: extension.ReactComponent,
        portalContainer: this.portalContainer,
      });
    }

    const namedComponents = entries({ ...this.options.nodeViewComponents, ...managerComponents });

    // Add the custom react components from the extension settings and manager setting.
    for (const [name, ReactComponent] of namedComponents) {
      nodeViews[name] = ReactNodeView.create({
        options: this.options,
        ReactComponent,
        portalContainer: this.portalContainer,
      });
    }

    return nodeViews;
  }
}

declare global {
  namespace Remirror {
    interface ManagerStore<Extension extends AnyExtension> {
      /**
       * The portal container which keeps track of all the React Portals
       * containing custom ProseMirror node views.
       */
      portalContainer: PortalContainer;
    }

    interface ExcludeOptions {
      /**
       * Whether to exclude the react components.
       *
       * @defaultValue undefined
       */
      reactComponents?: boolean;
    }

    interface BaseExtension {
      /**
       * Set the supported environments for this component. By default it is set
       * to use `both`.
       */
      reactComponentEnvironment?: ReactComponentEnvironment;

      /**
       * The component that will be rendered as a node view and dom element. Can
       * also be used to render in SSR.
       *
       * Use this if the automatic componentization in ReactSerializer of the
       * `toDOM` method doesn't produce the expected results in SSR.
       *
       * TODO move this into a separate NodeExtension and MarkExtension based
       * merged interface so that the props can be specified as `{ mark: Mark }`
       * or `{ node: ProsemirrorNode }`.
       */
      ReactComponent?: ComponentType<NodeViewComponentProps>;
    }

    interface ManagerSettings {
      /**
       * Override editor nodes with custom components..
       *
       * ```ts
       * {
       *   paragraph: ({ forwardRef }) => <p style={{ backgroundColor: 'pink' }} ref={forwardRef} />,
       * }
       * ```
       */
      nodeViewComponents?: Record<string, ComponentType<NodeViewComponentProps>>;
    }
  }
}

declare global {
  namespace Remirror {
    interface AllExtensions {
      reactComponent: ReactComponentExtension;
    }
  }
}