Skip to content

Feature suggestion: isolated/atomic operation groups in HistoryEditor #3874

Open
@arimah

Description

@arimah

Do you want to request a feature or report a bug?

Feature 🛠

Background

When developing an editor with rich functionality, it is occasionally necessary to implement high-level actions that comprise many low-level operations. For example, wrapping text in a link may involve first unwrapping an existing link in the selection – two (or more) low-level operations, one high-level actions. Or, perhaps you need to manipulate multiple blocks independently in order to, say, change indentation levels or create/remove lists.

For the user, these are single actions that should be undone with a single stroke of Ctrl+Z or Cmd+Z, and they should not be folded into previous interactions. Undoing a link insertion should not undo text that was entered before making the link, e.g.

One way to accomplish this is to use HistoryEditor.withoutMerging(), which prevents the next operation from being merged into the previous, effectively forcing a new history state. Unfortunately, it also creates new history states for all operations performed inside the callback. A workaround is to call withoutMerging() only for the first operation, if you need to perform many, but this quickly gets messy and it can be hard to track what operations have even been emitted.

Proposed solution

In addition to HistoryEditor.withoutMerging(), perhaps we can introduce a new HistoryEditor.atomic() (or asAtomic(), or isolated(), or asSingleState(), or whatever you want to call it), which always combines all of its operations into a single undoable state, and never merges with anything before or after it. This would make it trivial to write code along these lines:

// Everything here becomes one isolated history state.
HistoryEditor.atomic(editor, () => {
  MyTransforms.unwrapLink(editor);
  MyTransforms.wrapLink(editor, linkProps);
});

// And this is a separate, isolated state.
HistoryEditor.atomic(editor, () => {
  for (const [block, path] of affectedBlocks(editor)) {
    MyTransforms.doSomething(editor, block, path);
  }
});

As another example, suppose you want to transform * at the start of a line to a bullet list, but you want the user to be able to undo the space insertion and the list transformation independently. This would now be trivial to implement:

// First insert the space.
HistoryEditor.atomic(editor, () => {
  editor.insertText(' ');
});

// Then delete the trigger text and transform to a list.
HistoryEditor.atomic(editor, () => {
  Transforms.delete(editor, {
    distance: 2,
    reverse: true,
    unit: 'character',
  });
  MyTransforms.formatBulletList(editor);
});

This also means you can now safely use transformations that may result in multiple operations without worrying about "leaking" too many things into the history state. E.g. insertText is not safe to call inside withoutMerging() if the selection is not collapsed, as it will generate a remove_text operation followed by insert_text.

If the new proposed function is called within its own callback, as in

HistoryEditor.atomic(editor, () => {
  HistoryEditor.atomic(editor, () => { ... });
});

then the outermost call should probably become the only undoable history state.

Let me know what you think of this idea. :) I believe it would make many situations drastically simpler, and remove several of the footguns that are built into withoutMerging().

Unsolved problem: what happens if atomic() is called inside withoutMerging()? What about the reverse?

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions