We’ve been working on a plugin to bind Automerge and Prosemirror together and I wanted to check my approach was sane.
My strategy is to let Automerge handle what it’s good at: interleaving edits from a variety of sources, and let Prosemirror handle what it’s good at: being an extensible rich text editor. Thus, we write all Prosemirror transactions into Automerge immediately, and let Automerge produce patches which are converted into Prosemirror transactions.
I have experimented with a few different approaches: one is to let Prosemirror handle all local updates and simply mirror them to Automerge, but my concern is that this may be fragile in edge cases.
Perhaps the ideal race-condition-avoiding strategy would be to have a “closed loop” where all durable edits out of Prosemirror are run through the local Automerge object but it seems likely that there will be local Prosemirror state that Automerge should neither synchronize nor store.
In this case, I think I’d want to intervene in the transaction, apply the change to Automerge, collect its reply from how to update the patch and then combine that with the “rest” of the transaction but while I think this would improve fidelity of text editing it might introduce new problems with how other plugins behave.
A couple of the things we’ve tried and why they haven’t worked out:
writing changes into Automerge in the apply handler
This works provided the patch Automerge generates later is sufficiently (?) similar, in which case Prosemirror appears to “de-dupe” the change. It fails if the patch is too different and I worry the result is fragile. This may be salvageable if we just discard local patches.
intercepting prosemirror-originating transactions using filterTransaction and producing new “unrelated” transactions from the resulting Automerge changes.
This “works”, except it consumes other non-step edits such as cursor positions. I have a few ideas about how to propagate that information but I can only filterTransaction or appendTransaction. What I’d really like to do (I think?) is replace the transaction.
I should say that we have had some success with a variety of different approaches, including in the Peritext paper from last year and the recent Upwelling work as well, but both those approaches felt a little fragile and I’d like to get us on more principled footing.
Alright, I’ll leave it at that and hope for some feedback. I’m open to suggestions about either how to improve on our approach or entirely different approaches we should consider.
I think the approach Yjs has taken is pretty safe. Yjs intercepts the changes and syncs the ProseMirror doc, preventing race conditions with the change-origin meta field. Biggest issue with this is that Yjs replaces the whole document on remote changes eg. if somebody inserts a character and you receive that change, your whole ProseMirror doc is replaced with the one generated from Ydoc.
Not sure if Yjs is using filterTransaction but it should be pretty easy to intercept transactions that change the doc using the docChanged field. It allows letting through the cursor changes as well.
It’s possible to optimize to only replace part of the PM doc that was changed but it’s not a deal-breaker per se. Just annoying, especially with decorations.
writing changes into Automerge in the apply handler. This works provided the patch Automerge generates later is sufficiently (?) similar, in which case Prosemirror appears to “de-dupe” the change.
The approach you’re describing here was implemented in dispatchTransaction? That seems like the right place to do it (vs one of the plugin override-able hooks like append/filterTransaction), given that you’re ceding most of the control over prosemirror documents to Automerge anyways.
Is the API you’ve made for asking Automerge how to update/produce the patch asynchronous?
If the Automerge API is synchronous, I’d think there should be a way to avoid needing ProseMirror to detect de-duped changes later on entirely, by intervening in applyTransaction (which it sounds like you did). I’m not sure where/why ProseMirror would be de-duping substantially similar transactions in the way you described, and would be curious to hear more (or look at some code) to better understand what you observed.
If the Automerge API is asynchronous, it seems like this would be more complicated for other reasons: you have to record the local mutation to state, but might eventually want to unwind that mutation when or if Automerge replies with a differently structured patch.
One thing in general that it’d be nice to see in a more principled Automerge integration with ProseMirror is maybe in contrast to the Yjs approach @TeemuKoivisto described: something that could generate “real” ProseMirror transactions/steps for each remote Automerge change. Because Yjs avoids transactions entirely, instead replacing the entire document state for some changes, many ProseMirror plugins or behaviors have to be adjusted to work with Yjs (e.g. Yjs requires its own undo/redo plugin). The approach with Automerge you’re already experimenting sounds dreamy — where Automerge behaves similarly to prosemirror-collab in that it’s an external synchronization engine, but the steps it produces/consumes are basically vanilla ProseMirror steps!