Editing multiple nodes in one transaction

I have a custom node that has two attributes and text that is a number enclosed with square brackets, e.g. [1],[2], … [10]. An action triggers the code below to replace all the custom nodes in the editor. The code succesfully replaces the nodes sometimes. Sometimes the transaction leads to unexpected results, like an extra node is added with only part of the expected text.

<p>
    <a href="#node-8" data-position="7">[8]</a>
    <a href="#node-10" data-position="9">[</a>  <--- extra node added with only [ as text
    <a href="#node-9" data-position="8">[9]</a>
    <a href="#node-10" data-position="9">[10]</a>
    <a href="#node-11" data-position="10">[11]</a>
    <a href="#node-12" data-position="11">[12]</a>
    <a href="#node-13" data-position="12">[13]</a>
    <a href="#node-14" data-position="13">[14]</a>
    <a href="#node-15" data-position="14">[15]</a>
    <a href="#node-16" data-position="15">[16]</a>
    <a href="#node-17" data-position="16">[17]</a>
    <a href="#node-18" data-position="17">[18]</a>
</p>

Is this a proper way to replace all custom nodes in a single transaction?

import {findChildren} from "@tiptap/core";

const { schema, state, view } = editor.value;
const { doc, tr } = state;
const customNodes = findChildren(
    doc,
    node => {
        return node.type.name === CustomNode.name;
    }
);
customNodes.forEach(({ node, pos }) => {
    let oldNodePosition = node.attrs["data-position"];
    if (oldNodePosition > props.deletedNodePosition) {
        tr.replaceWith(
            pos,
            pos + node.nodeSize,
            schema.nodes.custom.create(
                {
                    href: `#node-${oldNodePosition}`,
                    "data-position": oldNodePosition - 1,
                },
                schema.text(`[${oldNodePosition}]`),
            ),
        );
    }
});
view.dispatch(tr);

Once you replaced a node at position x, then the next node to replace at position y (being y > x) likely changed its position too (the offset is the difference between the lengths of the contents being inserted and removed).

Two strategies:

  • use transaction mapping

  • replace nodes in inverse order of position, starting from the one nearest to the end of the document, down to the one closest to the start

Should we modify the transaction using tr.replaceWith(), then modify the StepMaps created by tr.replaceWith()?

Looking at tr.mapping, the StepMaps created by tr.replaceWith() are yielding some unexpected results.

Expected StepMaps when oldSize & newSize are the same, and when changed [9] changed to [8] has a StepMap (pos, 5, 5) [10] changed to [9] has a StepMap (pos, 6, 5)

did not expect this [11] changed to [10] has a StepMap (pos, 6, 8) … should be (pos, 6, 6) [12] changed to [11] has a StepMap (pos, 7, 7) … repeating (pos, 7, 7) for the remaining

Try this:

...
tr.replaceWith(
  tr.mapping.map(pos),
  tr.mapping.map(pos + node.nodeSize),
  schema.nodes.custom.create(
    {
      href: `#node-${oldNodePosition}`,
      "data-position": oldNodePosition - 1,
    },
    schema.text(`[${oldNodePosition}]`),
  ),
)

As long as you call tr.replaceWith, tr.mapping accumulates all the position mappings.

As I told you, another solution, that does not require mapping, is:

customNodes.sort((n1,n2) => n2.pos - n1.pos).forEach(...

You start replacing the last node, whose replacement does not influence the positions of the ones preceding it.

Keep in mind that the customNodes should be all separated; if you have one inside another, you must be careful when computing the nodeSize of the custom node that contains another, already replaced, custom node.

2 Likes

Works like a charm. I appreciate your help and guidance greatly in resolving this problem.

You’re welcome.

Prosemirror has a functional design, documents and nodes are immutable objects, they are treated as values, so you change them essentially making modified copies (never change nodes or marks in place, it’s looking for trouble).

But transforms behave like stateful objects, with tr.mapping keeping track of position changes.

Once you make a change – say a tr.insert in the body of a Command function – the document tr is looking at, tr.doc, has changed.

The next tr.insert will use that modified tr.doc, not the initial state.doc. So there may be a mismatch, if you use positions relative to the initial doc.

While state.doc keeps the initial state, tr.doc points at the current document after every modification; tr.mapping lets you map positions from state.doc to the latest tr.doc.

Usually that is enough, and you don’t need to look into single Steps.

In my experience, I seldom needed to look at single steps. Perhaps appendTransaction() is the only case I did.