Conceptual architecture for "mentions" plugin?

Hey there, I’m back into Prosemirror “for real” now and I need to create a “mentions” plugin. I’ve had a look at the ready-made ones and also the tiptap version but for various reasons I can’t use them as-is. So I’m looking for some guidance on building blocks to put something together myself.

The basic idea of the mentions functionality is that whenever either a user types @ or clicks an icon in the menu bar that inserts the same, a popup menu appears that shows you users (fetched from some backend).

  • The popup menu stays “locked” at the @ character.
  • As you keep typing, the users list is filtered down, and the characters you type show up in the editor too.
  • You can use the up/down arrow keys to highlight a user, enter to accept the selection and insert the mention, Esc or right/left arrow keys to cancel the autocomplete and probably some more rules to make it more intuitive.
  • When you accept a selection, a special Node is inserted with a user id. A NodeView deals with fetching user images and full names etc.

Seeing what other plugins do, they hook into ProseMirror Reference manual to get notified whenever the editor changes state, and use that to scan newly-typed text for matches to @, triggering a “start” event. Then if the plugin is active, they keep looking for extra characters to trigger “change” events, then finally an “exit” event if it’s cancelled.

I have also seen a trick where an inline decoration is added to matched text, sometimes with a unique id, so that it can be assigned a class or even be looked-up in the DOM by some other library (e.g. popper.js) to position a popup. This is managed by ProseMirror Reference manual .

Regarding the NodeView, the Plugin should also handle that via ProseMirror Reference manual .

I’m struggling to see where the pop-up logic belongs. As a UI component, I plan to implement it via React but I can’t figure out if it should be encapsulated in the Plugin.

Does this make sense as a general approach to implement a mentions/autocomplete plugin?

I would say it depends. As a rule of thumb, a mentions plugin is pretty common and reused pattern and plugin so I would try to keep the plugin state as general as possible with not-prosemirror stuff, and as specific as possible with prosemirror stuff.

As an example, here’s my attempt at an ‘autocomplete’ plugin (I also started with tiptap (v1) a while back but had to modify somewhat to fit ergonomically with React):

Things like regexp and style which the ProseMirror plugin needs is given as arguments. Meanwhile, things like what menu items to show and filter (popup logic) are kept outside of the plugin, and instead handled in the component file.

Notice that to keep the component in sync with the plugin, we still need to define an api surface by which those communicate: some state that the plugin gives to component onUpdate, and some keyboard event handlers (e.g. arrowDown moves active element down). And the way this is done is via an exported and imported plugin key, and an editor state which is given as props to the component.

Edit: In the gist, I did something hacky to resolve the synchronous plugin initialization with the asynchronous react component function definition:

let plugin = AutocompleteCommandsKey.get(editor.view.state);
plugin.onEnter = // react component handler

Issue is that in tiptap, the way to define event handlers is by passing them argument into the plugin initialization; but the react event handlers in the component may not exist yet.

If any one knows of a better method, please let me know!

Thanks for that gist, it clarifies what to do with the React state. I see that the way you chose to do it is to pre-render the React component and on mount hook up to the plugin API, then update the React state based on events that come in. I was trying to be clever with Portals and such and conditionally show/hide the UI inside a portal but it doesn’t work like that, it’d be a new component on every event.

Here’s a mention/autocomplete example I built that has some of those desired features.

In terms of rendering the popup, I just call ReactDOM.render and render in the correct position.