Using steps for plugin state changes

I’m writing a plugin that track ranges (from→to) in a document (one use-case is for inline-commenting), and I wanted to leverage the existing collab and history infrastructure as much as possible. To do this I have written some custom steps for creating, deleting, and moving a range, and have written an RFC to introduce a replaceTransaction API so that these steps can be injected into transactions when appropriate. For example:

  • deleting the entire content of a range, should delete the range
  • executing the undo command should restore the content and the the range
  • ranges should be synchronised in via prosemirror-collab

However there are a few problems with using Steps for this. Currently steps are intended for doc transforms only (they come from prosemirror-transform, not prosemirror-state), so they’re not aware of editor state or plugins. I’m investigating if it would be desirable to have them transform plugin state too. It seems desirable because it would allow plugins to be written that track their own data adjacent to the doc, and have that data stored adjacent in a backend, however it’s possible there’s some practicalities I haven’t taken into account that may make this approach unviable.

To have steps work for plugin state transforms, I think a few changes would be needed:

  1. prosemirror-collab wouldn’t look for doc changes tr.docChanged, instead it would assume all presence steps should be synced, and so just do a tr.steps.length > 0
  2. Create a plugin-aware Step in prosemirror-state (similar to how Transaction is a plugin-aware version of Transform), so that APIs like Step#invert and Step#apply work on an editor state rather than just a doc.
  3. Update prosemirror-collab and prosemirror-history to use the new Step.

(2) and (3) are not entirely necessary, a less desirable alternative is to make whatever initial state is needed to invert the step part of the step itself (i.e. pass it in via the constructor), so that it’s available in invert().

It’s actually perhaps not necessary to change Step#apply, as any changes to plugin state can be achieved through the plugin state’s apply method, so perhaps only Step#invert would need access to editor state, so that a plugin’s current state can be retrieved and used to produce the inverted step.

I’m also not sure about the implications for rebasing—what does it mean to rebase a plugin state transform—but I think it will just mean custom steps need to be carefully designed to be compatible with rebasing.

Does this seem like a viable direction to pursue, is anyone else interested in this?

Please see my comment on the RFC for some background.

My idea of how to keep plugin state and editor state in sync was to use transaction metadata, I see steps as document-changing things and nothing more. This makes them nicely scoped and simple to define.

But I’m aware that integrating such metadata with history and, to a lesser degree, collaborative editing is a huge pain. And treating non-document-changes like steps might be an elegant solution to that. However, I don’t really see how the subclass idea would work—steps are applied one at a time, but states are updated in one go, per transaction. Also, the subclass’ interface wouldn’t be a superset of the Step interface (you can’t apply or invert such steps in the same way as document steps).

For CodeMirror, which we’re rebuilding with a transaction model very similar to ProseMirror, we’re planning to make it possible to store custom items in the history. This hasn’t actually been planned out in detail, so that’s not all that helpful, but the plan would be to have a given transaction metadata slot that the history plugin looks at and, if present, stores its value before the transaction’s steps. When that item is undone, this value is applied in way where it can add to the transaction created for the undo to somehow restore some non-document state.

Such a thing would require a little more code than just having steps automatically picked up, but not that much. Because history isn’t just naive rollback in ProseMirror (and CodeMirror), such code would have to be careful to restore that state without getting out of sync with the document, but that issue would exist with steps too.

But again, as described in the RFC, you can’t just mutate transactions to, for example, automatically add data for deleted ranges. So maybe this is also not the right formulation, and we’d actually need something that allows the history to compute this undoable data when it’s storing a given step, rather than putting it in the transaction somewhere.

(That is, in fact, how docChanged is implemented (return this.steps.length > 0), but yeah, if we remove the assumption that steps apply to the document, it’d have to work differently.)

1 Like

This is what I’d envisaged (and is what I’m doing by naively storing plugin state on the document node). But given undo transactions may not be naive rollbacks, is there enough information in the system for the slots idea to work?

As for computing undoable data in the history plugin. I don’t quite understand this. Can you envisage a way of capturing enough information in this model for it to be generic? Or would you have to enumerate a lot of types of information to store in the history (things like deleted ranges etc.)?

I do like the simplicity and clarity of this model.

Editor states being updated at transaction boundaries, rather than step boundaries, indeed makes the step subclass approach quite leaky (I hadn’t thought really thought that through properly). And it’s not really possible to change editor states (specifically plugin state fields) to be updated at step boundaries, due to them typically operating on transaction metadata. That would be a substantially different architecture.

I think being computable is key, in the case of the range plugin the positions of the range need to be mapped during rebasing. Storing static values would suffer from becoming stale when rebasing takes place. I basically want to

Oops I should have dug a little deeper on that one. I am still planning on using custom steps to create and delete ranges (e.g. based on user interaction of creating / deleting a comment), so this would technically be abusing steps as no doc transform would be taking place. Perhaps I shouldn’t be trying to use steps, and should instead have custom actions controlled by the RangePlugin, and custom network code that ties together changes from prosemirror-collab and my range plugin, to send to the backend (much like the collab example on the website). If I went down that path I wonder if I’d need to solve conflict resolution again for the ranges (e.g. versioning, rebasing).

Thinking about this more, I think what I want to achieve might be similar to how selection information is stored in history. Perhaps thinking about how selection could be implemented as a plugin rather than a core feature could be useful.

@marijn I’d be really interested in seeing the shape of the API that you’re imagining prosemirror-history could expose that would support this use-case. I wouldn’t mind having a poke around with the implementation of it.

I’m unfortunately busy with other things and don’t plan to work on that design that anytime soon. You could try to take a stab at it yourself, or propose an API to get discussion started.