Separating state and view plugins

Hey @marijn, I did some experimenting with different approaches…

I’ll assume that we are on board with wanting to have the document state encapsulated in the application state. I tried a couple different approaches…

First Approach - StatePlugins and ViewPlugins

The first thing I tried is to separate the StatePlugins from the ViewPlugins. Currently, in my application, it’s the ViewPlugins that need to be decoupled.

class StatePlugin<T = any, S extends Schema = any> extends Plugin<T, S> {
	constructor(spec: Omit<PluginSpec<T, S>, "props" | "view">) {
		super(spec)
	}
}

class ViewPlugin<T = any, S extends Schema = any> extends Plugin<T, S> {
	constructor(spec: Pick<PluginSpec<T, S>, "props" | "view">) {
		super(spec)
	}
}

As such, the API can look something like:

const state = EditorState.create({doc, selection, plugins}) // these plugins are StatePlugins
const view = new EditorView(node, {state, nodeViews, plugins}) // these plugins are ViewPlugins

Looking at someProp and updatePluginViews in the EditorView class, I was able to get this kind of abstraction to work just by tearing apart the plugins.spec into EditorView direct props. I couldn’t quite get decorations to merge though, and I had to monkey-patch over view.updateState in order to update the plugin views.

That said, this abstraction could definitely work and has the benefit that you can construct ViewPlugins that depend on the application state.

// The application state.
const appState = {
  hotkey: "meta-k",
  editorState: EditorState.create(...)
}

// Rendering the app.
function render(node, state, dispatch) {
  const view = new EditorView(node, {
    state: state.editorState, 
    dispatchTransaction(tr) { dispatch(tr) },
    plugins: [
      keymap({
       [state.hotkey]: () => dispatch(...)
      })
    ]
  })
}

Second Approach - Break apart StatePlugins

Something I realized is that, I can imagine wanting StatePlugins to be configured by the app state. These plugins shouldn’t (I think) ever need to call dispatch so I we don’t have a circular dependency issue, but it does make it a little bit annoying. For example:

const pluginKey = new PluginKey("mention-autocomplete")

// This is a state plugin that can be configured to work with different trigger characters.
function AutocompleteStatePlugin(triggerCharacter) {
	return new StatePlugin({
		key: pluginKey,
		state: {
			init() {
				return { active: false }
			},
			apply(tr, state) {
				if (!state.active) return state

				const { range } = state
				const from = range.from === range.to ? range.from : tr.mapping.map(range.from)
				const to = tr.mapping.map(range.to)
				const text = tr.doc.textBetween(from, to, "\n", "\0")
				
				if (!text.startsWith(triggerCharacter)) {
					// Close when deleting the trigger character.
					return { active: false }
				}

				const queryText = text.slice(1) // Remove the leading trigger character.
				return {...state, range: { from, to }, text: queryText}
			},
		}
	})
}

const triggerCharacter = "@"
const appState = {
  triggerCharacter,
  editorState: EditorState.create({doc, selection, plugins: [
    AutocompleteStatePlugin(triggerCharacter)
  ]})
}

When constructing the app state, it isn’t a circular dependency, which is good, but it’s still a bit awkward because part of the state depends on other parts of the state. If the user changes the trigger character, we need to make sure to reconstruct the editorState which doesn’t exactly fit the functional model of the “view” as a function of “data”.

I’m starting to go out into unchartered territory, but ideally, we could pull applyTransaction out of EditorState.

Suppose EditorState is just data:

const state = EditorState.create({
  doc, 
  selection, 
  pluginStates: {[pluginKey]: { active: false}}
})

Then to apply a transaction, we have a pure function that is imported from prosemirror-state:

import {applyTransaction} from "prosemirror-state"

const nextState = applyTransaction(
  tr, 
  state, 
  pluginUpdates: {[pluginKey]: (tr, state) => { /* ... */} 
})

The nice thing here is that the plugin update function can depend on the current app state. That is, we can apply the autocomplete update function as a function of the trigger character that may change at any time.

This feels a little bit more about the Elm Architecture in the way that we’ve pulled apart the init/update for both the plugins and the editor and compose them together however we see fit.

And in this second approach, the ViewPlugins stay the same as the first approach, where they are arguments to EditorView.

Let me know if that makes sense. Thanks,

Chet