I’m building an in‑browser “track changes” system on top of Tiptap/ProseMirror that:
- Captures every real edit
On each
onUpdate
, grab the rawtransaction.steps
from the live editor (but skip history) and create a dummy state. - 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. - 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