Including filtered transactions in applyTransaction

Filtered transactions are great but because they inherently bypass potential state updates, there isn’t a great way to trigger a side effect when a transaction is filtered. If you prevented some user action and wanted to trigger a toast message that says “This action is not supported,” then you need an entirely different system to do this. The best you could do is trigger a side effect inside of the filterTransaction function itself (which breaks purity and would be separate from Prosemirror plugin code) or trigger an appended transaction that does the inverse of the transaction you wanted to filter (which is a bit complicated).

An easy way to potentially get around this is to return filtered transactions in the response of applyTransaction. You already receive the new state object and each applied transaction, plus the underlying code has access to each filtered transaction. You could then either use a custom prop or trigger side effects directly in your dispatchTransaction function on your view instance.

If there is interest, I could try to submit a PR. I think this would allow developers to better utilize filterTransaction.

Doesn’t checking whether the transaction you passed in is in the transactions property returned from applyTransaction already cover this?

Yea that would work for the initial transaction, but not appended transactions that are filtered. It’s also a bit less declarative as it may not be clear why no transactions were returned. Having filtered transactions returned would give the developer an explicit indicator that their transaction was filtered. That being said, tweaking the API just to be slightly more declarative is probably not reason enough to make the change.

Another alternative here is to add a new utility modifyTransactions that combines filterTransaction and appendTransaction. You would return a set of transactions to apply instead of the transactions passed in to the function.

So for example, in this use case, you would filter out the violating transaction and append another that sets the plugin state to show the error toast.

1 Like

This is exactly what I need.

I want to filter out a transaction, but then replace it with a different transaction:

Example:

When backspacing, I don’t want to delete an Image node I have. This I can handle in the filterTransaction.

In appendTransaction, I would take the cursor and reposition it so that it looks like backspacing skipped the Image node entirely.

As of now, it doesn’t seem like appendTransaction tells me what transactions were filtered, so I’d need to hack together side effects in filterTransaction to get this accomplished.

A modifyTransactions would be great.

I’ve hacked together a solution, but it seems incredibly flimsy and probably isn’t what should be happening in filterTransaction.

Please let me know if I totally missed something in the documentation as this is the only way I’ve managed to get my desired behavior.

filterTransaction: (transaction, state) => {
  const replaceSteps = [];

  transaction.steps.forEach((step, index) => {
    if (step.jsonID === "replace") {
      replaceSteps.push(index);
    }
  }

  replaceSteps.forEach((index) => {
    const map = transaction.mapping.maps[index];
    const ranges = map.ranges;

    const oldStart = ranges[0];
    const oldEnd = ranges[0] + ranges[1];
    const isDeleting = ranges[2] === 0;
    const isSimplyBackspacing  = ranges[1] === 1; // Simply meaning no text is selected.

    if (isDeleting && isSimplyBackspacing) {
      state.doc.nodesBetween(oldStart, oldEnd, (node) => {
        if (node.type.name === "image") {
          
          /**
           * Oof.
           * 
           * Mutated the transaction and pretended everything was okay just to be able to have the backspace
           * ignore the image and jump over it.
           * 
           * ProseMirror doesn't have a concept of modifyTransaction or replaceTransaction, so
           * this was what I came up with. We shall pray. YOLO
           */
          transaction.mapping.from = 0;
          transaction.mapping.to = 0;
          transaction.mapping.maps = [];
          transaction.steps = [];
          transaction.doc = transaction.before;
          const resolvedPos = transaction.doc.resolve(oldStart);
          transaction.setSelection(new TextSelection(resolvedPos, resolvedPos));
        }
      });
    }
  });

  return true;
}

Indeed, that’s horrible and violates all kinds of assumptions in the library.

There is no such thing as replaceTransaction. See the discussion on this RFC.

I really have no idea how I’d implement my feature then without something akin to replaceTransaction unless I go into appendTransaction and look for this backspace transaction, add a transaction that basically undoes it and moves the cursor.

Is that the recommended approach to solve something like this? I’d like to work within the spirit of the library - is the idea of creating an undo transaction for an existing transaction kinda straightforward?

Ope, I figured it out and it wasn’t as complicated as I thought (turns out getting some sleep is good for the mind).

Leaving solution here so anyone who runs into anything similar has something to reference:

Problem: When backspacing through the editor, I want to skip over images (user must explicitly delete images) so the cursor simply jumps over the image.

Originally, my approach was to use filterTransaction to stop the deletion of my image Node and then find a way to jump the cursor above the image Node. With no replaceTransaction concept in Prosemirror this is actually cumbersome (as you can see above where I completely hacked it together violating the good graces of this library lol).

The actual approach is to allow the image Node deletion to go through, but in appendTransaction you detect that this happened and just apply a new transaction that brings the image back to where it was.

appendTransaction: (transactions, oldState, newState) => {
  let trxToApply;

  transactions.forEach((trx) => {
    const replaceSteps: number[] = [];

    trx.steps.forEach((step, index) => {
      if ((step as any).jsonID === "replace") {
        replaceSteps.push(index);
      }
    });

    replaceSteps.forEach((index) => {
      const map = trx.mapping.maps[index];
      const ranges = (map as any).ranges as number[];

      const oldStart = ranges[0];
      const oldEnd = ranges[0] + ranges[1];
      const isDeleting = ranges[2] === 0;
      const isSimplyBackspacing = ranges[1] === 1; // i.e. text isn't selected while backspacing

      // In my scenario, I only care about a backspace deleting the image Node
      // Deletion through selecting and deleting/replacing entire editor content is perfectly fine
      if (isDeleting && isSimplyBackspacing) {
        oldState.doc.nodesBetween(oldStart, oldEnd, (node) => {
          if (node.type.name === "image") {
            trxToApply = newState.tr.replaceSelectionWith(node);
          }
        });
      }
    });
  });
  
  return trxToApply;
}

The recommended approach for that would actually be to bind a custom command to backspace that detects this situation and implements that behavior.

So this conversation ended up discussing something a bit different than my original proposal, but is there interest in returning transactions that were filtered when applying a transaction? I can’t really think of a downside and there are scenarios where it would be helpful to know which transactions were filtered.

1 Like