Skip some elements in history

Hi, I’m a beginner with prosemirror (I’m using it via tiptap) and I’m trying to remove from history some transactions that contain intermediate states (those states are simply choices user can do between content A and content B) so I don’t want them to be able to rollback to those states when they are closed.

I tried creating a tiptap extension to replace the default history and filter the transations I don’t want (intermediate states) :

import { Extension } from '@tiptap/core'
import { DOMSerializer } from '@tiptap/pm/model'
import { history, redo, undo } from 'prosemirror-history'
import { EditorState, Plugin, Transaction } from 'prosemirror-state'

export const CustomHistory = Extension.create({
    name: 'customHistory',

    addCommands() {
        return {
            undo: () => ({ state, dispatch }) => {
                return undo(state, dispatch)
            },
            redo: () => ({ state, dispatch }) => {
                return redo(state, dispatch)
            },
        }
    },

    addKeyboardShortcuts() {
        return {
            'Mod-z': () => this.editor.commands.undo(),
            'Shift-Mod-z': () => this.editor.commands.redo(),
            'Mod-y': () => this.editor.commands.redo(),

            'Mod-я': () => this.editor.commands.undo(),
            'Shift-Mod-я': () => this.editor.commands.redo(),
        }
    },

    addProseMirrorPlugins() {
        const historyPlugin = history({
            newGroupDelay: 500,
        })

        const customPlugin = new Plugin({
            ...historyPlugin.spec,
            filterTransaction(tr: Transaction, state: EditorState) {
                if (!tr.docChanged) {
                    return true;
                }

                const newContent = DOMSerializer.fromSchema(state.schema).serializeFragment(tr.doc.content);

                const newDiv = document.createElement("div");

                newDiv.appendChild(newContent);

                const shouldAddToHistory = !newDiv.innerHTML.includes("regen-component")
                console.log(tr.getMeta("addToHistory"))
                if (shouldAddToHistory) {
                    console.log(newDiv.innerHTML)
                } else {
                    tr.setMeta("addToHistory", false)
                }
                return true;
            },
        })

        return [customPlugin]
    },
})

According to the doc : " You can set an "addToHistory" metadata property of false on a transaction to prevent it from being rolled back by undo."

So that’s what I did with by filtering transactions having my intermediate node view (regen-component).

After that I set the meta “addToHistory” to false for those transactions to remove them from the history (that’s what I’m trying to do).

But as a result, I get some transactions removed from the history but not those I intended to actually remove.

I don’t really understand what I’m doing wrong since what I’m printing is correct :

 console.log(newDiv.innerHTML)

This shows exactly the content of transactions I’m trying to keep in my history.

Thanks in advance if you can help me solving this.

Your shouldAddToHistory check seems very dodgy (it does a bunch of unnecessary serialization, will match documents that have “regen-component” in their text, and looks like it will match whenever the document has that element in it, which doesn’t sound like it makes sense for checking whether a given transaction is of a specific type). But yes, "addToHistory" is how you make transactions not show up in history.

Can you explain a bit more what do you mean by :

which doesn’t sound like it makes sense for checking whether a given transaction is of a specific type

The way I see transaction is a way to move from a state to another : S(tate)1 + T(ransaction)1 = S2. So if my component is present in the new version (S2), I remove T1 from history.

Here I’m checking if the transaction HTML contains my element (that’s why I’m using DOMSerializer).

Maybe there’s something wrong in my logic but I can’t figure out what

The transaction which removes regen-component will be in history. Is this correct?

Also, you don´t need to serialize the document. You can inspect the prosemirror nodes directly.

I’m gonna explain step by step what I’m doing to make this easier to understand (I changed my approach removing the filtering and adding meta to every transaction I’m performing to be easier to track, but I still have the same problem).

Here is the workflow when the user performs an interaction using AI prompting (for the example, I used “translate in french”) :

Step 1 : I remove the user selection in the editor

editor.chain()
            .deleteRange({ from: fromPosition, to: toPosition })
            .focus()
            .setTextSelection(from)
            .focus()
            .run();

Step 2 : I insert my component “regenComponent” (the function retryFC is a way to restart the stream if it failed for one reason or another).

function retryFC() {
            editor.state.doc.descendants((node, pos) => {
                if (node.type.name === 'regenComponent') {
                    editor.chain().focus().setMeta("addToHistory", false).command(({ tr }) => {
                        tr.setNodeMarkup(pos, undefined, {
                            ...node.attrs,
                            isError: false,
                            isFinished: false,
                            errorMessage: false
                        });
                        return true;
                    }).run();
                }
            });
            handleStreamResponse()
        }

editor.chain().focus().setMeta("addToHistory", false)
            .insertContentAt(fromPosition, {
                type: 'regenComponent',
                attrs: {
                    content: '',
                    before: html,
                    from: from,
                    to: to,
                    isStart: isSelectionFromStart,
                    isEnd: isSelectionToEnd,
                    isSingleNode: isSingleNode,
                    listTag: listTag,
                    isFirstListItem: isFirstListItem,
                    onRetry: () => retryFC()
                }
            }).run();

Step 3 : I call the stream function that will feed my regenComponent

const handleStreamResponse = async () => {
            let accumulatedContent = '';

            const streamCallback = (chunk: string) => {
                accumulatedContent += chunk.replaceAll("\n", "");

                editor.state.doc.descendants((node, pos) => {
                    if (node.type.name === 'regenComponent') {
                        editor.chain().setMeta("addToHistory", false).focus().command(({ tr }) => {
                            tr.setNodeMarkup(pos, undefined, {
                                ...node.attrs,
                                content: node.attrs.content + chunk,
                            });
                            return true;
                        }).run();
                    }
                });
            };

            try {
                const res = await f_GetStreamResponse(
                    context?.data.preferences.email!,
                    prompt,
                    article.id,
                    streamCallback,
                    article.title,
                    "selected_paragraph",
                    hierarchy,
                    textAfter,
                    textBefore,
                    "",
                    fullText,
                    html
                );

                if (res.success) { // Success, pass component to end mode
                    editor.state.doc.descendants((node, pos) => {
                        if (node.type.name === 'regenComponent') {
                            editor.chain().setMeta("addToHistory", false).focus().command(({ tr }) => {
                                tr.setNodeMarkup(pos, undefined, {
                                    ...node.attrs,
                                    isFinished: true,
                                });
                                return true;
                            }).run();
                        }
                    });
                } else { // Error, pass component to error mode
                    editor.state.doc.descendants((node, pos) => {
                        if (node.type.name === 'regenComponent') {
                            editor.chain().setMeta("addToHistory", false).focus().command(({ tr }) => {
                                tr.setNodeMarkup(pos, undefined, {
                                    ...node.attrs,
                                    isError: true,
                                    isFinished: true,
                                    errorMessage: res.message
                                });
                                return true;
                            }).run();
                        }
                    });
                }
            } catch (error) {
                editor.state.doc.descendants((node, pos) => {
                    if (node.type.name === 'regenComponent') {
                        editor.chain().setMeta("addToHistory", false).focus().command(({ tr }) => {
                            tr.setNodeMarkup(pos, undefined, {
                                ...node.attrs,
                                isError: true,
                                isFinished: true,
                                errorMessage: "An unexpected error happened, please try again"
                            });
                            return true;
                        }).run();
                    }
                });
            }
        };

handleStreamResponse();

Step 4 : When the stream is finished, the user can click to choose between the previous content or the new one, in both case I remove the regenComponent node before inserting the desired content

editor.chain().focus().setMeta("addToHistory", false)
        // Remove the current node
        .deleteRange({ from: position, to: position + node.nodeSize })
        .run();

Step 5 : I paste the choosen content into the editor :

editor.chain().focus()
        // Insert content at the same position
        .insertContentAt(position, newContent.replace(/\n/g, ""), {
          parseOptions: { preserveWhitespace: false },
        })
        .focus()
        .run();

Everything works well except for the history part. At that time when I click “undo”, the editor turns empty :

It skips correctly the regenComponent I want to avoid but doesn’t return to the initial state (the state having “my text”). And that’s what I’m unable to understand for now.

My first intuition was that as I didn’t use .setMeta("addToHistory", false) in the step 1, this was the transaction that was used when I clicked “undo” and that’s why I got a blank editor.

The problem is, after clicking undo, I have nothing else in the history as I can undo only one time. So my initial state completely disappeared.

And even if I add .setMeta("addToHistory", false) at Step 1, the behavior is exactly the same.

And yes I know the code is not optimized for now, it’s while I’m looking for a solution to fix this