How is UndoManager being used?

I curious about how the yjs UndoManager is actually being used. I am puzzled because the undo stack is shared among all clients. So, for example, if user A inserts ‘X’; then user B inserts ‘Y’; and then user ‘A’ pops an item off the undo stack, the effect is that ‘Y’, not ‘X’ is removed. But this is unlikely to feel natural to users. When User A presses the undo button (or whatever the UI provides to pop an item off the undo stack) they almost certainly intend to undo their own action, not user Y’s action. If they wanted to get rid of Y, they would, I feel, be much more likely to use a ‘delete’ command.

Do you know of a use case in which a shared undo stack is appropriate?

I did a quick check and, for instance, Google Docs and tldraw both implement per-user undo stacks rather than one shared stack.

If you have implemented a per-user (i.e. per client) undo stack, how did you do it? How did you achieve persistence (i.e. the stack remains even if the user exits and resumes)? Do you care about dependencies e.g. User A inserts ‘CAT’, User B modifies it to ‘DOG’, User A tries to undo their action - with what result? Is DOG deleted (it is a modified version of User A’s inserted CAT) or does the undo fail (that is what happens with Google Doc - the undo silently fails).

Any thoughts or examples very welcome…

1 Like

Thank you for expressing your doubts about UndoManager in the community.

I went to check the relevant code logic and found that UndoManager project does share the stack internally.

The reference code is as follows:

However, if you want to achieve localization (client/user level) stack, I think that you can refer to trackedOrigins

Thanks for confirming that the UndoManager is shared. While trackedOrigins may be useful for some applications, it is not clear to me how it could be used to implement a per-user undo stack. Can you explain?

hi,@micrology

UndoManager records changes based on transaction , when we mark transaction through origins, only transaction with the same mark will be recorded, so as to achieve the purpose of localization.

more detail please see here:

1 Like

Hi @jarone - thanks for the info on this. I’m also interested in this topic.

I’m a little confused about the transaction origin concept. It seems like it is undefined for all transactions by default? Is there any way to add a per session origin to all transactions by default? Maybe by using doc.on('beforeTransaction', function(tr: Transaction, doc: Y.Doc)) (link).

Another question - if I’m using Y.Array.push() or other methods that create a transaction, am I able to add a transaction origin to those?

Would the doc.clientId be appropriate to use as the transaction origin?

I’m also curious as to the best way to selectively add transactions to the stack. the stopCapturing() method doesn’t seem to work as I expected. I guess this is more a piece of feedback, but I would think based on how I understand that function to work, it would make more sense to call it stopMerging().

I guess the above could also be implemented using the tracked origins thing, just don’t include the origin if you don’t want the history item included… which then comes around a little bit full circle to my first question… maybe it’s best not to add a “default origin” for all transactions.

Thanks again for your help :pray:

hi, @NGimbal

Yes, UndoManager is checking origin in afterTransaction’s callback function, so we can theoretically control origin in beforeTransaction’s callback function.

This is the code to prove this theory.

import * as Y from 'yjs';

const doc = new Y.Doc();
const ytext = doc.getText('text');

doc.on('beforeTransaction', (tr, doc) => {
  console.log(
    'enter beforeTransaction:',
    {
      origin: tr.origin
    }
  );

  if (!tr.origin) tr.origin = 'the origin for UndoManager';
});

ytext.observe((yEvent, tr) => {
  console.log(
    'enter observe:',
    {
      origin: tr.origin,
    }
  );
});

doc.transact(() => {
  ytext.insert(0, 'a');
});

doc.transact(() => {
  ytext.insert(1, 'b');
}, 'my-custom-transaction-origin')

the output:

enter beforeTransaction: { origin: null }
enter observe: { origin: 'the origin for UndoManager' }
enter beforeTransaction: { origin: 'my-custom-transaction-origin' }
enter observe: { origin: 'my-custom-transaction-origin' }

It seems like it is undefined for all transactions by default?

If origin is not set, then its default value is null.

Would the doc.clientId be appropriate to use as the transaction origin?

The level of isolation strategy that UndoManager uses depends on your needs.

For example, if you want to achieve user-level isolation, then the user’s ID as the origin is a feasible strategy (note: it may span multiple browser tabs, which requires actual exploration and confirmation), if you use doc.clientId as the value of origin, then the isolation level of Y is the smallest granularity (because doc.clientId is unique after each initialization)

I personally do not recommend using doc.clientId.
:slightly_smiling_face:

2 Likes

Ok :pray: Thanks so much.

Looks like the question of which origin to use is a UX issue. Using a user id as an origin sounds like a great idea. The experience of undoing across tabs would be kind of crazy. Thanks!

1 Like

Hi. We are using y-codemirror.next.

We want each client to be able to undo only their own input.
Is there a good way to add origin?

We will use ydoc.clientID for origin to Y.UndoManager.

const ytext = ydoc.getText('codemirror');

const undoManager = new Y.UndoManager(ytext, {
  trackedOrigins: new Set([ydoc.clientID]),
});

const cleanup = codeMirrorEditor?.appendExtensions?.([
  yCollab(ytext, provider.awareness, { undoManager }),
]);

How to do each client to be able to undo only their own input.
Still undoes another client’s changes also.
I am wondering if YSyncConfig is the cause.

  • I was specified userName as trackedOrigins.
  const attachYDocExtensionsToCodeMirror = () => {
    if (ydoc == null || provider == null) {
      return;
    }

    const ytext = ydoc.getText('codemirror');
    const undoManager = new Y.UndoManager(ytext, {
      trackedOrigins: new Set([userName]),
    });

    const cleanup = codeMirrorEditor?.appendExtensions?.([
      yCollab(ytext, provider.awareness, { undoManager }),
    ]);

    console.log('code', codeMirrorEditor);

    return cleanup;
  };
enter beforeTransaction: {origin: YSyncConfig}
enter observe: {origin: YSyncConfig}
enter beforeTransaction: {origin: d}

I had been using react-codemirror and it seemed to conflict with the history feature of react-codemirror, so I disabled the history feature of react-codemirror and used Y.UndoManager, so it worked as expected. Thanks.

1 Like