Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

What is a minimal way of implementing suggestions (autocomplete) with prosemirror-svelte? #1

Open
jimbojetlag opened this issue Jan 26, 2020 · 12 comments
Labels
architecture API and architecture related discussions

Comments

@jimbojetlag
Copy link

Tiptap and tiptap-svelte come with an implementation of suggestions (autocomplete), but I personally find their approach complicated and busy.

As you can see, the suggestions example of tiptap-svelte import many moving parts, where PM and svelte are not well separated.

I really like the approach in this project over Tiptap, I think it makes the overall application code size less and with less complexity. What would be the right way of implementing a suggestion capability using this library?

This does not have to be part of the library, but an example of demonstrating how to implement such thing would be great.

@christianheine
Copy link
Owner

Do you mean autocomplete for a single-value field or hashtag/mention support within a document?

Let me assume that you mean Hashtags/Mentions (because this is usually a bit more involved).

Essentially, these individual points need to be solved:

  1. Identify that the text around the current cursor position fulfills the criteria for the hashtag/mention (typically by using a regular expression). Can be done inside a plugin or in an event handler for on:transaction.
  2. Show a popup near the current cursor position. You can easily get the coordinates of the current cursor position with editorView.coordsAtPos - which you can feed the current selection (head)
  3. Provide means to the user to select an option from the available ones, i.e. by listening to the keydown event. It found it works best from inside a prosemirror plugin, to properly return "false" to prosemirror - telling it that this key event should not be handled by prosemirror any further.
  4. Modify the editor state based on the user selection (could be done inside or outside the plugin)

Note: Point number 4 could be done by either simply replacing some text (and rendering hashtags by a decoration plugin) or by replacing it with a new (inline) entity from your schema.

OK. There are two ways to approach all of this:

  1. Handle everything (including rendering the popup UI) inside a prosemirror plugin. This is also how some existing plugins are made.
  2. Handle event management (parsing editor state around the current cursor position and also keyboard events) inside a prosemirror plugin and then dispatch "tag" events to your Svelte component to render the appropriate UI on top of the editor.

Prosemirror-Svelte is essentially a result of using Prosemirror inside a (yet to launch) production application and in that context I decided to go for the second route. It works well, but unfortunately it's rather specific code and would not serve as an easily digestible example.

Nevertheless, also based on other feedback, I recently have been contemplating a lot about how to best provide extension functionality and especially how to merge Svelte and non-Svelte frameworks like Prosemirror. Probably the best solution (especially for a modular framework) would be to render Svelte components directly from a prosemirror plugin.

I already built a basic prototype to to do just that (it's not that hard using the client component api but have not yet decided on a decent API to put this into this library.

I should be able to allocate some time later this week to take another shot at this.

Until then: I hope this already helped a bit.

@jimbojetlag
Copy link
Author

Thanks for the detailed explanation.

Do you mean autocomplete for a single-value field or hashtag/mention support within a document?

I'm not sure what you mean by single-value field, but mention is definitely what I meant.

In a more general way, Atlassian's Atlaskit has a great React implementation of a comprehensive Prosemirror-based editor, which also includes autocomplete, see this example, try to type @ in editor. However, their implementation is really huge, mostly specific to their platform and hard to carve out for reuse.

Probably the best solution (especially for a modular framework) would be to render Svelte components directly from a prosemirror plugin.

This architecture may stop us using existing pure PM plugins, fo instance prosemirror-suggestions plugin which is headless and leaves the ui to library user. I think ui plugins should to be designed in this way to be platform independent.

We need some layer of abstraction to make building Svelte ui on top such plugins easier. I do not know if there is a general enough pattern to let such abstraction happen.

Prosemirror-Svelte is essentially a result of using Prosemirror inside a (yet to launch) production application

Glad to know this is being used in production and it's not just a hobby project!

@christianheine
Copy link
Owner

Probably the best solution (especially for a modular framework) would be to render Svelte components directly from a prosemirror plugin.

This architecture may stop us using existing pure PM plugins, fo instance prosemirror-suggestions plugin which is headless and leaves the ui to library user. I think ui plugins should to be designed in this way to be platform independent.

Prosemirror-Suggestions (i.e. just dispatching events from the plugin) is essentially what I did before. Yet, this has the downside that the UI on top is decoupled from the plugin. At the beginning I actually liked this approach, but I more and more feel like it would be better to encapsulate all behavior into the plugin (mainly because the way how Svelte handles component composition). Maybe even going as far as encapsulating the schema extension. To ensure the plugin stays "renderless", the render logic could be passed when creating the plugin instance.

In some frameworks, like React, I would probably choose something like passing a renderProp to the plugin. Svelte does not really have this concept - even slots require an existing component. But it would be possible to pass a complete component to be rendered to the plugin.

What I meant was something along the lines of this example. It's really just a rough prototype, but it proofs that it's possible to instantiate the plugin with a component - which can communicate with the other Svelte components, e.g. using stores.

@jimbojetlag
Copy link
Author

Yet, this has the downside that the UI on top is decoupled from the plugin.

I actually I find this a plus. In prosemirror-suggestions plugin, there is a lot of logic that can and should be shared between different ui frameworks. There is no reason to once implement this logic for Vue, then for Svelte, then for React etc.

Feels like this is a design problem with Prosemirror, where plugins must be hardwired to the UI. Maybe it needs something like a concept of plugin models where the logic can be shared among frameworks.

Of course what can be done today is to simply setup a renderless plugin like prosemirror-suggestions with even listeners that render svelte components. I believe this is what your example is supposed to do, but it did not work for me when I tried to run it.

@christianheine
Copy link
Owner

Feels like this is a design problem with Prosemirror, where plugins must be hardwired to the UI. Maybe it needs something like a concept of plugin models where the logic can be shared among frameworks.

Well, to a large degree that is possible with Prosemirror, but at some point you need to render UI... I guess it's more an issue with a lot of frameworks. Svelte - to my experience - is relatively flexible in this regard. It lets you render components from anywhere using the client component API. In the meantime I created another prototype on my local machine and it worked pretty well.

I believe this is what your example is supposed to do, but it did not work for me when I tried to run it.

The example renders a popup at the current cursor (selection). It moves with the cursor as long as the selection is empty and hides once text is selected. The component is rendered using svelte but from within a plugin.

Screenshot 2020-01-29 at 12 04 31

The plugin is defined in plugin.js. It takes a Svelte component (which in this example should implement the props top, left and visible) and then updates it when the prosemirror editor view updates.

import {Plugin} from 'prosemirror-state'

export const currentPosition = Component => new Plugin({
		view: function view (editorView) {
			const _popup = new Component({
								target: document.body,
								props: { top: 100, left: 0, visible: false }
			})
			
			return { 
				_popup,
				update: function update(editorView) {
					const selection = editorView.state.selection
					const visible = selection.empty && editorView.hasFocus()
					const pos = editorView.coordsAtPos(selection.head)
					this._popup.$set({top: pos.top + 20, left: pos.left, visible})
				},
				destroy: function destroy() {
					this._popup.$destroy()
				}		

Here is the point where the plugin is instantiated and receives the Svelte component:

	import Popup from './Popup.svelte'
	import { currentPosition, focusEvents } from './plugin.js'
	
	const currentPositionPopup = currentPosition(Popup)

Previously I implemented the tag functionality like this:

<ProsemirrorEditor {editorState}
        ...
        on:tagover={handleTagOver}
        on:tagleave={handleTagLeave}/>

{#if tags}
    <TagSelector 
        ...
        {tags}         
        on:select={handleSelectTag}/>
{/if}

It means that any component that potentially needs to supports tags needs to setup their own handlers and UI. If the whole functionality could be encapsulated inside a plugin then the components can stay "clean" and depending on how the editor state is initialized, functionality is added on top.
If the UI would be completely framework agnostic, then each implementation would need to use a bit more advanced functionality (e.g. using the Svelte client side API) - and also it would mean we need to expose an html node to mount the new components on. So overall, I would feel better to have some degree of coupling to the framework (this whole project is about providing bindings for Prosemirror to Svelte) in order to provide a consistent API experience for whoever wants to use it. And if someone wants a UI agnostic plugin, it's always possible to use something like prosemirror-suggestions.

@christianheine christianheine added the architecture API and architecture related discussions label Jan 29, 2020
@jimbojetlag
Copy link
Author

I may be missing some point here. In this part, what is the benefit of passing the Popup component to the plugin generator?

const currentPositionPopup = currentPosition(Popup)

Why not instantiate Popup component directly in plugin.js?

export const currentPosition = () => new Plugin({
	view: function view (editorView) {
		const _popup = new Popup({
			target: document.body,
			props: { top: 100, left: 0, visible: false }
		})
// ...

@christianheine
Copy link
Owner

This way the plugin only contains its core logic but leaves the definition of the UI to the caller.

The plugin (e.g. to show hashtags or a menu) could be provided by the library - passing props from the current editor state/ editor props to it (e.g. where the cursor is currently located or whether it is currently inside a hashtag). And the component to render a UI based on that props could be provided by whoever wants to use the plugin.

In frameworks like React, it would probably be more elegant to use render props, but this concept does not really exist in Svelte (there are slots, but they would be tied to the editor - and therefore make it difficult/ impossible for external plugins to use them).

Note: The example uses document.body for simplicity sake. But it is possible to use a node around the editor as mounting point as well, therefore also not causing any affects to the rest of the page. I already tried to conditionally render the editor with components rendered next to it and they properly mount/unmount along with the editor.

/** Called when the plugin is linked to the editor view
     * @param editorView {EditorView} */
    view(editorView) {
      const state = pluginKey.getState(editorView.state);

      let svelteComponent;

      if (Component) {
        const editorDiv = editorView.dom;
        const container = editorDiv.parentNode;

        svelteComponent = new Component({
          target: container,
          props: {...}
        });

      }

@liflovs
Copy link

liflovs commented Jun 18, 2020

@christianheine , sorry for the offtopic, didn't want to create issue for this, but do you plan to improve this repo? I thought it was kind of dead until you responded last issue

@christianheine
Copy link
Owner

Fully understand your concern. The answer is a bit complicated.

The repo started out from the personal need to link between Prosemirror and Svelte. I needed a flexible and extensible text editor, but I did not want to have a heavy overhead like TipTap (I felt that Prosemirror was already powerful enough). So I created this very straightforward integration and open-sourced it because I wanted more people harness the power of these two frameworks. ProsemirrorSvelte was born. Btw, it's not just a pet project, but it powers the text entry in my startup Faden (https://www.faden.cloud/).

Quickly after ProsemirrorSvelte's inception, I received various requests on how to do certain things. But, at a deeper level almost all of them were really about "how do I do certain things in Prosemirror"? Prosemirror is a great framework, but due to its flexibility & modularity it requires a certain amount of upfront investment to really understand how things work. I feel it's worth the time, but I understand that some people just want a drop-in editor.

In the app I mentioned above, I also realized that some things - like mapping the prosemirror state to a different data model - came up repeatedly.

I did not want to bloat this repo (since some people might just want the EditorState directly), so I started to an abstraction layer on top of this one (the project was christened "Promira").

Promira got all the bells and whistles which would be required to make the whole thing more accessible. It uses a kind of wrapped editor state, which makes transformation, serialization & deserialization much easier. But, it always had some annoying quirks when the two worlds were bridged (e.g. selection control from reactive updates. Especially when the user is currently editing).

I found stable ways to address these in the above mentioned app, but these fixes required an even deeper level of understanding of both Prosemirror and Svelte than if you would use the basic integration. In fact, without wanting it, I went halfway down the "TipTap" route (which is not a bad thing. Others tried the same. It's just a huge time commitment).

My current plan is to take the best from Promira but move back to the core philosophy of this repo - using EditorState directly. Let me take another look at this over the weekend. At least to give you a better idea on what I could commit to in foreseeable future.

Out of curiosity: What are you mainly missing from this repo?

@christianheine
Copy link
Owner

OK, so I reviewed the current codebase. I think I should be able to ease out the edges in 1-2 days of effort. Will try to get this done over the next ~2 weeks.

@liflovs
Copy link

liflovs commented Jun 23, 2020

@christianheine it was not smth in particular. I just started with it and it felt like a too basic experience for fast development. I used createTextEditor functions but then I realized they need customization options which they don`t have, so I modified your files and started going deep into Prosemirror, unfortunately on this stage I always have not much time in bulk to get it finished.
And iteratively its hard to get things done cause you start recalling what is what each time...

Generally speaking I was looking for a library that can simplify Prosemirror development, perhaps by taking away some features from me. Or having implemented examples that will be enough to cover basic needs. Think same feeling as a topic starter.

Generally speaking I think a lot of guys are looking for the most basics things, and if any of these can be implemented / simplified - that would be cool:

  1. Menu / nodes UI defined in svelte components.
  2. Ability to react on prosemirror state changes (contents / selection / cursor position)
  3. Convenient (maybe a bit reduced in functionality comparing to Prosemirror) ability to change state from outside of prosemirror given a selection / cursor position

@christianheine
Copy link
Owner

Another month has passed and no updates here, I know. For the last few of months, I've been pulled deeper and deeper into the mobile development rabbit hole with Flutter & Dart. So for anyone waiting for an update, let me at least express that I'm sorry things are pretty stale here.

In the meantime, I stumbled upon another repo by @PierBover
svelte-prosemirror-example which covers a number of use cases when combining Prosemirror with Svelte. Definitely worth a look.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
architecture API and architecture related discussions
Projects
None yet
Development

No branches or pull requests

3 participants