Locally mirrored/synced nodes between two editor instances

I’ve been banging my head against the wall with this one, so I’m reaching out to ask for advice and if I can implement something working I’ll share what I have. What I am trying to achieve is to have 2 local editor views that are a combination of independent and shared nodes.

  1. If a node has attribute mirrored false it behaves normally and is self contained within the editor it was created
  2. If a node has attribute mirrored true, then any changes to that node in either editor would change it in the other
  3. Deleting a mirrored node deletes it in both editors
  4. When the mirrored attribute is set from false to true, a copy of that node is appended to the other editor and changes are propagated as per point 1 and 2
  5. When the mirrored attribute is set from true to false, the node in the opposite editor is deleted

I had some ideas on how to approach this, and if you have a better solution or help on how best to implement one of these it would really be appreciated

  1. One view and state with 2 node views that filter content based on the attributes of nodes and a plugin that tracks changes to nodes and updates the corresponding node as outlined in the points above (probably by assigning a unique ID to each node on creation)
  2. Using a y.js approach I’ve seen used for online collaboration and try to wrangle that into this use case (this feels excessive since I can guarantee that changes are only happening to one editor at once)
  3. A document with 3 root nodes: editorA, editorB and mirrored. Then editors will conditionally display these and, attribute changes will correspond to changing the parent node (there is no nesting of block level nodes in my editor). This would cause problems with positioning though

There is furthermore the complication of getting undo/redo history to work, and I wouldn’t know where to start with that

This should be possible by just forwarding steps inside such nodes between the editors (with an offset corresponding the the difference between the position of the node in the other editor). There are of course awkward corner cases like steps that cross from inside a node to outside it (or even into another mirrored node). How to handle those will require additional consideration (though it might be reasonable to just filter them out).

Thanks for the quick reply, can you please elaborate on how one would go about “forwarding steps […] between the editors”. I’m also using tiptap so I’m not sure if you can de-couple editorview and editorstate?

Hey,

I did a similar thing ( although it was the same nodes in single doc, and I had to save them to an external place + sync them ). What you have to do is transpose transactions relative to the start of your node, and transpose that transaction again for your other editor ( transposing = creating a new transaction and applying a mapping to the steps of the old transaction ). Only for transactions which touch one of your nodes ofc.

Redo is also solvable, you can apply metadata to your transactions so that prosemirror-history won’t track some of them ( if you don’t want to track the mirrored changes ).

This is definitely doable, but there are corner cases, it depends on what you want.

Quite a task you got yourself there. All your options seem quite fancy - why not just use a visibility attribute and hide the private nodes from others? Otherwise it seems you’ll have to inspect every transaction if they touch shared/private nodes and rebase them so that only shared changes are propagated. And if they are again toggled, handle that case as well.

But if you really want private nodes for some reason, I’d possibly go with multiple editorViews. You’d have to combine the histories somehow but avoiding tracking transactions saves you a lot of pain. Although I guess you could just encrypt the node content well - which would make them unreadable by other users and keep them private. Anyway, good luck!

I’ve actually tried to solve the problem the way Teemu suggested, it works but I had some issues on IOS so I decided to do the other way. It worked, and it’s not a hard thing to do, although you lose a few things ( like selection cross-boundary )

Thanks for all the help so far, I really appreciate it. I’ve decided not to go with Teemu’s suggestion because I’d like the position of the mirrored nodes to be independent in each document. Instead I’ve been using offsets like marijn suggested (although I haven’t worked on transactions across the mirror node boundaries); but it sometimes works and mostly doesn’t. I made an issue about it on tiptap which solved it in some cases. Essentially most transactions cause an Invalid content for node error. I have attached a code sandbox with this issue if anyone smarter than me wants to try debugging it or at least have a look to see how I’ve approached this so far.

For those following along, the solution to the problem in the code sandbox is extremely subtle; when creating nodes the code is:

nodeType.create(attrs, content, marks)

but it should be:

nodeType.createChecked(
  attrs,
  Fragment.fromJSON(schema, fragment.toJSON(content));,
  marks.map((m) => Mark.fromJSON(schema, m.toJSON())),
)

I really think that create() should have a warning attached suggesting to use createChecked() because this would have saved me hours of headaches. Nonetheless, when I get a fully working version of this I’ll create a codesandbox to help anyone else out.

EDIT: added the (mostly) working codesandbox