Feedback on an ideal React interface

update: i implemented this, checkout https://bangle.dev

Hey Folks I have been working on a higher level tool kit for Prosemirror and wanted to get some feedback on some ideas.

I have broken down this post into 3 ideas.

Separation of schema and plugins

const emojiSuggestKey = new PluginKey('emojiSuggestKey');
const emojisArray = [
  ['japanese_ogre', '👹'],
  ['shallow_pan_of_food', '🥘'],
];
const schema = new Schema([
      ...coreSpec(),
      emojiSuggest.spec({ markName: 'emojiSuggest', trigger: ':' }),
])

const  plugins = () => [
      ...corePlugins(),
      emojiSuggest.plugins({
        key: emojiSuggestKey,
        emojis: emojisArray,
        markName: 'emojiSuggest',
      }),
 ]

export default function Example() {
  const editorState = useEditorState({
    schema,
    plugins: plugins(),
    initialValue: 'Hello there!',
  });

  return (
    < EditorView state={editorState}>
      <EmojiSuggest emojiSuggestKey={emojiSuggestKey} emojis={emojisArray} />
    </EditorView>
  );
}

I have seen some libraries (tiptap) mixing node/mark spec and PM plugins in a single class which sort of works as a plug-n-play component. I initially took the same approach, but then hit a roadblock where I wanted to share the schema between multiple editor views and a collaboration server. Sharing the schema is doable but I found the coupling of everything in one giant class is an uphill battle against the modular architecture of PM. I am curious to hear what folks think about this, do the benefits of fusing schema, plugins, keybindings, etc in one class help in larger and more complex applications ?

Using React hooks


export function EmojiSuggest({ emojiSuggestKey, emojis }) {
  const view = useEditorViewContext();
  const emojiPluginState = usePluginState(emojiSuggestKey);
  // Extract the DOM Node from the plugin state to demonstrate a one way data flow from  
  // PM to react component. 
  return reactDOM.createPortal(<SomeUIComponent />, emojiPluginState.tooltipContentDOM)
}

In the example above I am using hooks to persist (between renders) creation of EditorState and bunch of other things. The benefit of custom hooks like these is that it makes things modular – similar to PM’s architecture.

I struggled with a bunch of ways to solve the ‘sharing of PM state with XYZ React component’ and settled with saving related things in a Plugin state and then passing it down as a prop as shown in the example above. Every time the plugin’s state changes, usePluginState will re-render the EmojiSuggest component. In my opinion the biggest benefit is the practice of uplifting the state to parent component and passing it down to children. A parent component in this case happens to be a PM plugin.

A problematic aspect of linking React and Prosemirror is the rerendering on every transaction. Prosemirror expects you to have numerous transactions, each having a fast turn around time, but if each transaction triggered a React update our application would come to a grinding halt. In the example above I am leveraging the plugin’s state to shield the React component from only updating when the plugin’s state changes. Some easy wins with this approach is the managing of DOM node which is more appropriately handled by PM and then passed to React to render.

Extensibility

This is not really related to React but would love to bounce some ideas on this. The plugin architecture of PM works great, but PM leaves ambiguity on how to extend and share specs and plugins.

To give a hypothetical example:

  • We have a PM tooltip module which contains 2 plugins.
  • We have an Emoji Suggest module which builds on top of tooltip module and also introduces its own plugins.
  • The consumer of Emoji Suggest library might already be using the tooltip library.

What would be the best way to connect these components, without causing dependency issues? Currently I am thinking that Emoji Suggest module should internally create its own tooltip plugin instances and expose the entire thing as flat array to the user of the library.


// The emoji suggest library
export const emojiPlugins = ({ opts }) => {
  return [
    tooltip.plugins({ ... }),
    someEmojiPlugin,
  ]
}

// User using the library
const  plugins = () => [
      ...corePlugins(),
      emojiPlugins({ opts }),
 ]

With this approach each component ends up creating its own plugins instead of expecting the user of the library to setup tooltip plugin. However with this approach, I am not sure if I should be worrying about the total number plugins in an application. If having more plugins linearly impacts the latency of application, this approach will not work great. There can be an alternative way where we sort expect the user to setup tooltip plugin and somehow use it to display our tooltip, but I worry the developer experience of this approach would be bad.

I would love to hear what you all think about the above ideas, any feedback, thoughts, improvements?

PS: The above ideas have mostly been inspired after combing through a bunch of already existing awesome topics in this website, thanks you all <3.

1 Like

Hello @kepta, why not just saving PM view into React’s context. As you might end up with more than 1 PM view in your application. So you could have things like context.setView() , context.updateView when PM view actual performs a transaction, context.deleteView() etc. Afterwards, on your component, you can call React’s context, and by using useMemo hook you can define when you actually want your component to rerender. I don’t see the need for all these plugins passed down as props, as context can hold all the info you need for your components.

1 Like

Hello @kepta, why not just saving PM view into React’s context.

Thanks for the @ChristosK, if I am understanding this correctly, yes, in my example the hook useEditorViewContext() is indeed using and providing PM’s view from the context, sorry if it was not super clear.

I don’t see the need for all these plugins passed down as props, as context can hold all the info you need for your components.

Oh thanks to this, I just realized there is a stray const view = useEditorViewContext(); in <EmojiSuggest />, updated my original post.

I was suggesting not to pass the plugins, but the pluginKey as a prop and have a hook like usePluginState to get the pluginState for the component, which internally relies on useEditorViewContext. This should in theory lead to cleaner code like the following:

export function EmojiSuggest({ emojiSuggestKey, emojis }) {
  const emojiPluginState = usePluginState(emojiSuggestKey);
  return reactDOM.createPortal(<SomeUIComponent />, emojiPluginState.tooltipContentDOM)
}


export function usePluginState(pluginKey) {
  const view = useEditorViewContext();
  const [state, setState] = useState(pluginKey.getState(view.state));

  useEffect(() => {
    const plugin = new Plugin({
      key: new PluginKey(`withPluginState_${pluginKey.key}`),
      view() {
        return {
          update(view, prevState) {
            const { state } = view;
            if (prevState === state) {
              return;
            }
            const newPluginState = pluginKey.getState(state);

            if (newPluginState !== pluginKey.getState(prevState)) {
              setState(newPluginState);
            }
          },
        };
      },
    });
    reconfigurePlugins.add(view, plugin);
    return () => {
      reconfigurePlugins.remove(view, plugin);
    };
  }, [view, pluginKey]);

  return state;
}

Great initiative, @kepta ! Have a look to remirror.io for inspiration. They came up with a couple of really elegant solutions for the challenges that you raised.

Have a look to remirror.io 3 for inspiration. They came up with a couple of really elegant solutions for the challenges that you raised.

I have been following remirror for a while now and I am not sure I could find how the challenges about are addressed in the library? Could show a direct example ?

Super interesting project. Thanks for sharing!

For anyone interested, i have implemented the above ideas in https://bangle.dev , see this introductory post BangleJS: higher level Prosemirror components