Just wanna know,Will this work!?

I’m building an in‑browser “track changes” system on top of Tiptap/ProseMirror that:

  1. Captures every real edit On each onUpdate, grab the raw transaction.steps from the live editor (but skip history) and create a dummy state.
  2. Marks insertions and deletions for each ReplaceStep (or wrap other step types), I wrap newly inserted content in an “insertion” mark and flag ranges in a “deletion” mark, using a brand‑new transaction on the shadow state.
  3. Persists the diffed JSON After applying all changes, I extract the shadow state’s document back to JSON and save it. That JSON now carries embedded change markers so that i can review what was inserted/deleted.

THE ISSUE I’M FACING

Since after deletion,the positions in the main documents shift,but in the shadow state,the deletion is preserved (but marked with deletion marks),so im facing position mis-alignments.Especially for bulletLists.

SHARING THE CODE

        onUpdate: async ({ editor, transaction }) => {
          try {
            console.log('[DEBUG] Transaction received:', transaction);
            console.log('[DEBUG] Transaction steps:', transaction.steps);

            if (transaction.getMeta('history$')) {
              console.log('[DEBUG] History transaction detected, skipping tracking logic');
              triggerCallback(editor, transaction); //this is for custom nodes which call updateAttributes
              return;
            }

            //validates if its a valid transaction with steps
            if (shouldSkipTransaction(transaction)) {
              console.log('[DEBUG] Transaction should be skipped (custom logic)');
              triggerCallback(editor, transaction);
              return;
            }


            const steps = transaction.steps.map((step) =>
              Step.fromJSON(editor.schema, step.toJSON())
            );
            const parentState = $currentEditorState //svelte store that saves the initial state of editor before any changes
            const schema = $currentEditorState.schema;
            if (!schema.marks.insertion || !schema.marks.deletion) {
              console.error('🚨 Schema missing "insertion" or "deletion" marks.');
              return;
            }

            let changeTransaction = parentState.tr;
            changeTransaction.setMeta('wrappedTransaction', true);

            const mapping = new Mapping();

            steps.forEach((step, index) => {
              console.log(`[DEBUG] Processing step ${index}:`, step);

              if (!(step instanceof ReplaceStep || step instanceof ReplaceAroundStep)) {
                console.log(`[DEBUG] Step ${index} is not a ReplaceStep`);
                try {
                  const mappedStep = step.map(mapping);
                  if (mappedStep) {
                    const stepResult = changeTransaction.maybeStep(mappedStep);
                    if (stepResult.failed) {
                      console.warn(`[DEBUG] Failed to apply step ${index}: ${stepResult.failed}`);
                    } else {
                      mapping.appendMap(mappedStep.getMap());
                      console.log(`[DEBUG] Applied and mapped step ${index}`);
                    }
                  }
                } catch (err) {
                  console.error(`[DEBUG] Error applying step ${index}:`, err);
                }
                return;
              }

              const from = mapping.map(step.from);
              const to = mapping.map(step.to);
              const slice = step.slice;
              const sliceSize = slice.content.size;

              // Record the number of steps before processing the current step
              const stepsBefore = changeTransaction.steps.length;

              // INSERTION
              // for Insertion ,we need to add the content and also mark it.
              if (sliceSize > 0) {
                changeTransaction.replaceWith(from, from, slice.content);
                changeTransaction.addMark(
                  from,
                  from + slice.content.size,
                  editor.schema.marks.insertion.create()
                );
              }

              // DELETION
              // for deletion we dont need to add the content as it will already be present in the saved state,we just need to mark the range
              if (step.from !== step.to) {
                console.log(`[DEBUG] DELETION at [${from}, ${to}]`);

                try {
                  changeTransaction.addMark(from, to, schema.marks.deletion.create());
                  console.log(`[DEBUG] Deletion mark applied: [${from}, ${to}]`);
                } catch (err) {
                  console.error(`[DEBUG] Error applying deletion mark:`, err);
                }
              }

              // Update mapping with steps added to changeTransaction
              const stepsAdded = changeTransaction.steps.slice(stepsBefore);
              stepsAdded.forEach((addedStep) => {
                mapping.appendMap(addedStep.getMap());
              });
            });

            if (changeTransaction.steps.length > 0) {
              try {
               trackChangesEditor = parentState.apply(changeTransaction);
                trackChangesEditorStore.set(trackChangesEditor);
              } catch (err) {
                console.error('[DEBUG] Error applying transaction :', err);
              }
            } else {
              console.log('[DEBUG] No changes detected in changeTransaction.');
            }

            triggerCallback(editor, transaction);
            console.log('[DEBUG] onUpdate finished.');
          } catch (err) {
            console.error('💥 Error in onUpdate handler:', err);
          }
        }

OBSERVATIONS / RESULTS

After 2 edits (deleting some content and pasting some content),the resultant json is

{
  "type": "doc",
  "content": [
    {
      "type": "heading",
      "attrs": {
        "level": 1,
        "componentUUID": "1122b63f-7a4b-47ca-9068-1b890eb84807"
      },
      "content": [
        {
          "type": "text",
          "text": "ClickStream Events"
        }
      ]
    },
    {
      "type": "paragraph",
      "attrs": {
        "componentUUID": "6189f3d6-f32f-4f3b-8826-d0289c008247"
      },
      "content": [
        {
          "type": "text",
          "text": "Clickstream events refer to a series of actions or interactions a user takes when browsing a mobile app. These events are recorded and analyzed by businesses to gain insights into user behavior and preferences. "
        }
      ]
    },
    {
      "type": "paragraph",
      "attrs": {
        "componentUUID": "6189f3d6-f32f-4f3b-8826-d0289c008247"
      },
      "content": [
        {
          "type": "text",
          "marks": [
            {
              "type": "insertion"
            }
          ],
          "text": "I’ve enhanced the utility to "
        },
        {
          "type": "text",
          "marks": [
            {
              "type": "bold"
            },
            {
              "type": "insertion"
            }
          ],
          "text": "sanitize"
        },
        {
          "type": "text",
          "marks": [
            {
              "type": "insertion"
            }
          ],
          "text": " your Tiptap JSON before converting:"
        }
      ]
    },
    {
      "type": "bulletList",
      "attrs": {
        "tight": false
      },
      "content": [
        {
          "type": "listItem",
          "content": [
            {
              "type": "paragraph",
              "attrs": {},
              "content": [
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "bold"
                    },
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": "Drops"
                },
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": " any node whose "
                },
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "code"
                    },
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": "type"
                },
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": " isn’t in "
                },
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "code"
                    },
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": "schema.nodes"
                },
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": "."
                }
              ]
            }
          ]
        },
        {
          "type": "listItem",
          "content": [
            {
              "type": "paragraph",
              "attrs": {},
              "content": [
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "bold"
                    },
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": "Filters"
                },
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": " out unknown marks."
                }
              ]
            }
          ]
        },
        {
          "type": "listItem",
          "content": [
            {
              "type": "paragraph",
              "attrs": {},
              "content": [
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "bold"
                    },
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": "Removes"
                },
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": " empty text or container nodes so you never hit "
                },
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "code"
                    },
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": "Unknown node type: undefined"
                },
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": "."
                }
              ]
            }
          ]
        },
        {
          "type": "listItem",
          "content": [
            {
              "type": "paragraph",
              "attrs": {},
              "content": [
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "bold"
                    },
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": "Falls back"
                },
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": " to an empty doc if the entire root is invalid."
                }
              ]
            }
          ]
        }
      ]
    },
    {
      "type": "paragraph",
      "attrs": {
        "componentUUID": "6189f3d6-f32f-4f3b-8826-d0289c008247"
      }
    },
    {
      "type": "paragraph",
      "attrs": {
        "componentUUID": "6189f3d6-f32f-4f3b-8826-d0289c008247"
      },
      "content": [
        {
          "type": "text",
          "marks": [
            {
              "type": "deletion"
            }
          ],
          "text": "Clickstream events may include clicks on buttons, checkbox clicked, payment method selected, upi apps selected and more . By analyzing clickstream data, businesses can optimize their app design, improve user experience, and increase conversion rates."
        }
      ]
    }
  ]
}

which is perfectly fine and same as expected. After some editing (typing not pasting content),the result is:

{
  "type": "doc",
  "content": [
    {
      "type": "heading",
      "attrs": {
        "level": 1,
        "componentUUID": "1122b63f-7a4b-47ca-9068-1b890eb84807"
      },
      "content": [
        {
          "type": "text",
          "text": "ClickStream Events"
        }
      ]
    },
    {
      "type": "paragraph",
      "attrs": {
        "componentUUID": "6189f3d6-f32f-4f3b-8826-d0289c008247"
      },
      "content": [
        {
          "type": "text",
          "text": "Clickstream events refer to a series of actions or interactions a user takes when browsing a mobile app. These events are recorded and analyzed by businesses to gain insights into user behavior and preferences. "
        }
      ]
    },
    {
      "type": "paragraph",
      "attrs": {
        "componentUUID": "6189f3d6-f32f-4f3b-8826-d0289c008247"
      },
      "content": [
        {
          "type": "text",
          "marks": [
            {
              "type": "insertion"
            }
          ],
          "text": "I’ve enhanced the utility to "
        },
        {
          "type": "text",
          "marks": [
            {
              "type": "bold"
            },
            {
              "type": "insertion"
            }
          ],
          "text": "sanitize"
        },
        {
          "type": "text",
          "marks": [
            {
              "type": "insertion"
            }
          ],
          "text": " your Tiptap JSON before converting:"
        }
      ]
    },
    {
      "type": "bulletList",
      "attrs": {
        "tight": false
      },
      "content": [
        {
          "type": "listItem",
          "content": [
            {
              "type": "paragraph",
              "attrs": {},
              "content": [
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "bold"
                    },
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": "Drops"
                },
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": " any node whose "
                },
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "code"
                    },
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": "type"
                },
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": " isn’t in "
                },
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "code"
                    },
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": "schema.nodes"
                },
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": "."
                }
              ]
            }
          ]
        },
        {
          "type": "listItem",
          "content": [
            {
              "type": "paragraph",
              "attrs": {},
              "content": [
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "bold"
                    },
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": "Filters"
                },
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": " out unknown marks."
                }
              ]
            }
          ]
        },
        {
          "type": "listItem",
          "content": [
            {
              "type": "paragraph",
              "attrs": {},
              "content": [
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "bold"
                    },
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": "Removes"
                },
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": " empty text or container nodes so you never hit "
                },
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "code"
                    },
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": "Unknown node type: undefined"
                },
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": "."
                }
              ]
            }
          ]
        },
        {
          "type": "listItem",
          "content": [
            {
              "type": "paragraph",
              "attrs": {},
              "content": [
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "bold"
                    },
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": "Falls back"
                },
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": " to an empty doc if the entire root is invalid."
                }
              ]
            }
          ]
        },
        {
          "type": "listItem",
          "content": [
            {
              "type": "paragraph",
              "attrs": {},
              "content": [
                {
                  "type": "text",
                  "marks": [
                    {
                      "type": "insertion"
                    }
                  ],
                  "text": "w content has been added here"
                },
                {
                  "type": "text",
                  "text": "e"
                }
              ]
            },
            {
              "type": "paragraph",
              "attrs": {},
              "content": [
                {
                  "type": "text",
                  "text": "n"
                }
              ]
            }
          ]
        }
      ]
    },
    {
      "type": "paragraph",
      "attrs": {
        "componentUUID": "6189f3d6-f32f-4f3b-8826-d0289c008247"
      },
      "content": [
        {
          "type": "text",
          "marks": [
            {
              "type": "deletion"
            }
          ],
          "text": "Clickstream events may include clicks on buttons, checkbox clicked, payment method selected, upi apps selected and more . By analyzing clickstream data, businesses can optimize their app design, improve user experience, and increase conversion rates."
        }
      ]
    }
  ]
}

i typed “new content has been added here” which should be a para next to the bulletList,but it gets messed up. Next is somthing entirely typed

{
  "type": "doc",
  "content": [
    {
      "type": "heading",
      "attrs": {
        "level": 1,
        "componentUUID": "1122b63f-7a4b-47ca-9068-1b890eb84807"
      },
      "content": [
        {
          "type": "text",
          "text": "ClickStream Events"
        }
      ]
    },
    {
      "type": "paragraph",
      "attrs": {
        "componentUUID": "6189f3d6-f32f-4f3b-8826-d0289c008247"
      },
      "content": [
        {
          "type": "text",
          "text": "Clickstream events refer to a series of actions or interactions a user takes when browsing a mobile app. These events are recorded and analyzed by businesses to gain insights into user behavior and preferences. "
        }
      ]
    },
    {
      "type": "paragraph",
      "attrs": {
        "componentUUID": "6189f3d6-f32f-4f3b-8826-d0289c008247"
      },
      "content": [
        {
          "type": "text",
          "marks": [
            {
              "type": "insertion"
            }
          ],
          "text": "some new content which is not pasted but typed has been added here"
        }
      ]
    },
    {
      "type": "paragraph",
      "attrs": {
        "componentUUID": "6189f3d6-f32f-4f3b-8826-d0289c008247"
      },
      "content": [
        {
          "type": "text",
          "marks": [
            {
              "type": "deletion"
            }
          ],
          "text": "Cl"
        },
        {
          "type": "text",
          "marks": [
            {
              "type": "insertion"
            }
          ],
          "text": "bullet 1"
        }
      ]
    },
    {
      "type": "paragraph",
      "attrs": {
        "componentUUID": "6189f3d6-f32f-4f3b-8826-d0289c008247"
      },
      "content": [
        {
          "type": "text",
          "marks": [
            {
              "type": "deletion"
            }
          ],
          "text": "ic"
        },
        {
          "type": "text",
          "marks": [
            {
              "type": "insertion"
            }
          ],
          "text": "bullet 2"
        },
        {
          "type": "text",
          "marks": [
            {
              "type": "deletion"
            }
          ],
          "text": "kstream events may include clicks on buttons, checkbox clicked, payment method selected, upi apps selected and more . By analyzing clickstream data, businesses can optimize their app design, improve user experience, and increase conversion rates."
        }
      ]
    }
  ]
}

you can see that it messes up the bulletList structure,where the expected result should be like

{
  "type": "doc",
  "content": [
    {
      "type": "heading",
      "attrs": {
        "level": 1,
        "componentUUID": "1122b63f-7a4b-47ca-9068-1b890eb84807"
      },
      "content": [
        {
          "type": "text",
          "text": "ClickStream Events"
        }
      ]
    },
    {
      "type": "paragraph",
      "attrs": {
        "componentUUID": "6189f3d6-f32f-4f3b-8826-d0289c008247"
      },
      "content": [
        {
          "type": "text",
          "text": "Clickstream events refer to a series of actions or interactions a user takes when browsing a mobile app. These events are recorded and analyzed by businesses to gain insights into user behavior and preferences. "
        }
      ]
    },
    {
      "type": "paragraph",
      "attrs": {
        "componentUUID": "dbf6baa2-29a9-4b12-9169-15af9bb7b0b1"
      },
      "content": [
        {
          "type": "text",
          "text": "some new content which is not pasted but typed has been added here"
        }
      ]
    },
    {
      "type": "bulletList",
      "attrs": {
        "tight": true,
        "componentUUID": "0bfbc1b2-3f47-43e6-9217-29241fc3c1e5"
      },
      "content": [
        {
          "type": "listItem",
          "content": [
            {
              "type": "paragraph",
              "attrs": {
                "componentUUID": "91970bde-00df-4496-b9e2-bc4095e90420"
              },
              "content": [
                {
                  "type": "text",
                  "text": "bullet 1"
                }
              ]
            }
          ]
        },
        {
          "type": "listItem",
          "content": [
            {
              "type": "paragraph",
              "attrs": {},
              "content": [
                {
                  "type": "text",
                  "text": "bullet 2"
                }
              ]
            }
          ]
        }
      ]
    }
  ]
}

At this point im confused that,is this approach entirely feasible!? is it the perfect solution!? I know i can normalize the document into plain text and diff and re-arrange it,but that wont be suitable for my usecase as i have custom nodes which cannot be rebuilt from plain text!.

@marijn your thoughts!? This thread might help in accomplishing the much expected tracking changes functionality in prosemirror without marks that appear during editing which i think might be a bad UX

The way I recommend you do change tracking is still to keep change information entirely separate from the document, and use decorations to show the changes. I cannot really imagine a mark-based approach that doesn’t get very complex and dodgy.

After deleting some and pasting some :

After typing “new content added here” : Only typing,not pasting : but expected outcome is :

can you elaborate,please. From what i understand is you’re telling me to create a array of changes or some kind where i would store like

{
  type : "insertion",
  from : "<>",
  to : "<>",
  content :  "<>"
}
{
  type : "deletion",
  from : "<>",
  to : "<>"
}

and apply decorations!? Isn’t the same problem, position mis-alignments may occur in this?

This is pretty much what prosemirror-changeset is trying to solve. It tracks changes and updates them as new changes come in.

still @marijn ,just a last doubt. i guess that the positions emiited by a changeset are relative to the initial state of document and i managed to get changes like

[
    {
        "type": "deletion",
        "fromA": 6,
        "toA": 11,
        "fromB": 6,
        "toB": 6,
        "slice": {
            "content": [
                {
                    "type": "text",
                    "text": "equis"
                }
            ]
        }
    }
]
[
    {
        "type": "insertion",
        "fromA": 14,
        "toA": 14,
        "fromB": 9,
        "toB": 10,
        "slice": {
            "content": [
                {
                    "type": "text",
                    "text": "q"
                }
            ]
        }
    }
]

so flow would be like,first get all deletions (we may not need the slice as the initial doc should contain that already) and map the positions and run decorations or marks to those ranges,once we completed the deletions then we proceed to insertions and insert the content in the initial doc state.Just wanna confirm any caveats or did i miss anything!? I already used particular amount of time in previous approach ,so i would like to get a review from an expert before proceeding!

No, they tell you their positions both in the start and in the end document.

yeah,i understood that by the logs emitted,fromA,toA relates to positions corresponding to old state and toA,toB relates to new state.I’m just trying to figure out whether i would encounter the same position mis-alignment caused in the previous approach!?

and another case is if i deleted a range where a bulletlist exists and then try to type a paragraph,the text would me inserted in a range where the bulletlist exist,so this would be structurally incorrect,i think!

So, WILL THIS WORK !? :slight_smile:

@marijn What do you personally think about these 2 approaches,IMO i think its hardly possible with many edge cases!