Is there a way to change elements in the undo/redo logs? [UndoManager]

Hello!

I am working on a collaborative drawing application and am using Yjs as the backbone for state synchronization. Btw it is a great lib and I’m really grateful that is exists!

I use XML elements to store the shapes, each element has a few properties like position, rotation, scale etc. Based on this info I draw them to the canvas.

Now I am trying to add a distributed undo/redo support and I the built-in UndoManager seems to be a good fit. It works fantastic except for one use case:

  1. User A creates a shape S
  2. User B rotates S
  3. User A undoes (removes the shape)
  4. User A redoes (adds the shape back)

In this case I’d expect to get the rotated shape back, but it seems the redo puts the non-rotated version back.

As far as I understand to resolve this issue I have to update the redo log whenever there is an undo and update the undo log when there is a redo (based on this: How Figma’s multiplayer technology works?

I can see that there are two hook points for the UndoManager: stack-item-added/stack-item-popped, but unfortunately I am stuck. I don’t know how to modify the stack and so on.

So my question is what approach should I follow here? How can I modify the undo/redo log elements if it is even possible?

Thank you!

Welcome to the forum @rideg!

You should get back the rotated object as you described. Could you please provide a minimal example that I can fix?

You can use transaction origins to perform an action without tracking the change. E.g.

doc.transact(() => {
  ytext.delete(1, 1)
}, 'my custom transaction origin')

Thank you @dmonad!

I’ve created a sample code and I can verify that one works as expected.
So I took another look at my code and realized that the root cause is something else.

I have my custom grpc-web provider which does the following during document load:

this.doc.transact((transaction: Y.Transaction) => {
  data.forEach((update) => {
     Y.applyUpdate(transaction.doc, update as Uint8Array, origin);
  });
}, RemoteOrigin);

I have a go backend where I don’t merge the changes, just store the binary payloads and dump the whole array to to the client when it joins. So I merge the changes on the client side.

In the binding code I have something similar:

this.fragment.observeDeep((events) => {
   events.forEach(this.handleChange.bind(this));
}

and

handleChange(evt) {
  // Handle addition
  evt.changes.added.forEach((item) => 
     item.content.getContent().forEach((cont) => {/* add to canvas */})
  );
  // Handle property change
  if (evt.path.length > 0 ) {
     this.handleAttributeChanges(evt.target, evt.changes.keys);
  }
 // Handle deletion
  evt.changes.deleted.forEach((item) => {...  });
}

And what I realized: during the initial load the change events are merged by type (addition, change, deletion) so if I create a shape and the change the rotation, during load I will get two events, one for the add and fore the change. However during the redo I only receive the addition event.
Maybe that is the intended behavior, I don’t know. I managed to fix my problem by adding an is inited flag to the binding so at load time I drop the change events.

Is this approach ok?

Since Y.applyUpdate is wrapped in a transaction this change will only fire a single event with the RemoteOrigin transaction (the origin transaction-origin will be ignored because it is created in another transaction originating from RemoteOrigin). Make sure that RemoteOrigin is not tracked by the UndoManager unless you want to.

All in all this sounds about right. There is probably a more elegant solution to make this work, but whatever works, works!