Tracking Changes by wrapping insertions and deletions with marks

I’m a bit of beginner in prosemirror ,let me explain my project first,its a tiptap editor with lot of custom nodes,that tiptap json in rendered into html by custom renderers (NoteRenderer,ImageRenderer,etc..) and the tiptap json structure is in a custom way.when user goes into edit mode,the Editor instance is rendered and onupdate,each renderer has a callback that updates the json according to the custom json structure.on the Editor im doing something like this (code is a mixture from AI garbage,github(trackchangesExtesnion)by some guy and myself)

			onUpdate: ({ editor, transaction }) => {
				try {
					// Skip if this transaction was generated by our code
					if (transaction.getMeta('isOurReAddTransaction')) {
						console.log('Skipping our transaction');
						return;
					}

					if (!transaction.docChanged) {
						return;
					}
					// if this is a redo or undo, ignore it.
					if (transaction.getMeta('history$')) {
						return;
					}
					// check if it is synced by another client or the server, need to ignore too
					const syncMeta = transaction.getMeta('y-sync$');
					if (syncMeta && syncMeta.isChangeOrigin) {
						return;
					}
					// has no real step
					if (!transaction.steps.length) {
						return;
					}

					// Extract valid steps from transaction
					const allSteps = transaction.steps.map((step) =>
						Step.fromJSON(editor.state.doc.type.schema, step.toJSON())
					);

					// Track cursor position and offset
					const currentCursorPos = editor.state.selection.from;
					let cursorOffset = 0;
					let isReplace = false;

					// First pass: calculate offsets and identify replacements
					allSteps.forEach((step: Step, _index: number, _arr: Step[]) => {
						if (step instanceof ReplaceStep) {
							let delCount = 0;
							if (step.from !== step.to) {
								const slice = transaction.docs[_index].slice(step.from, step.to);
								slice.content.forEach((node) => {
									const isInsertNode = node.marks.find((m) => m.type.name === MARK_INSERTION);
									if (!isInsertNode) {
										delCount += node.nodeSize;
									}
								});
							}
							cursorOffset += delCount;
							const newCount = step.slice ? step.slice.size : 0;
							if (newCount && delCount) {
								isReplace = true;
							}
						}
					});

					if (!isReplace) {
						cursorOffset = 0;
					}

					// Create a single transaction for all our changes
					let tr = editor.state.tr;
					tr.setMeta('isOurReAddTransaction', true);

					// Handle insertions and deletions in one pass
					let reAddOffset = 0;

					// Process insertion steps
					allSteps.forEach((step: Step, index: number) => {
						if (step instanceof ReplaceStep && step.slice?.content?.size > 0) {
							const from = step.from + reAddOffset;
							const to = step.from + reAddOffset + step.slice.size;

							// Add mark for inserted content
							tr = tr.addMark(from, to, editor.schema.marks.insertion.create());
							tr = tr.removeMark(from, to, editor.schema.marks.deletion);
						}
					});

					// Process deletion steps
					allSteps.forEach((step: Step, index: number) => {
						if (step instanceof ReplaceStep && step.from !== step.to) {
							const invertedStep = step.invert(transaction.docs[index]);

							// Calculate offset for re-insertion point
							const reAddFrom = invertedStep.from + reAddOffset;

							// Create a list to track nodes we need to skip
							const nodesToSkip: { from: number; to: number }[] = [];

							// Function to identify nodes with insertion marks
							const findInsertionMarks = (content: Fragment, parentOffset: number) => {
								content.forEach((node, offset: number) => {
									const start = parentOffset + offset;
									const end = start + node.nodeSize;

									if (node.content && node.content.size) {
										// This node has children, traverse them
										findInsertionMarks(node.content, start);
									} else {
										// Check for insertion mark
										if (
											node.marks.find(
												(m: { type: { name: string } }) => m.type.name === MARK_INSERTION
											)
										) {
											nodesToSkip.push({ from: start, to: end });
										}
									}
								});
							};

							// Find nodes to skip
							findInsertionMarks(invertedStep.slice.content, invertedStep.from);

							// First, re-add the deleted content
							tr = tr.replace(reAddFrom, reAddFrom, invertedStep.slice);

							// Add highlight to the re-added content
							const reAddTo = reAddFrom + invertedStep.slice.size;
							tr = tr.addMark(reAddFrom, reAddTo, editor.schema.marks.deletion.create());

							// Process nodes that need to be skipped (removed again)
							// We need to adjust positions based on where content was re-added
							let skipOffset = 0;
							nodesToSkip.sort((a, b) => b.from - a.from); // Process from end to start to avoid position shifts

							nodesToSkip.forEach(({ from, to }) => {
								// Calculate position in the current document
								// Adjust from/to by:
								// 1. Subtract original position (invertedStep.from)
								// 2. Add new insertion position (reAddFrom)
								// 3. Subtract any offset from previous removals
								const skipFrom = from - invertedStep.from + reAddFrom - skipOffset;
								const skipTo = to - invertedStep.from + reAddFrom - skipOffset;

								// Remove this node
								tr = tr.delete(skipFrom, skipTo);

								// Update offset for future skip operations
								skipOffset += skipTo - skipFrom;
							});

							console.log('nodesToSkip', nodesToSkip);

							// Update the offset for future operations - account for nodes we removed
							reAddOffset += invertedStep.slice.size - skipOffset;

							// Position cursor at the end of the re-added content, adjusted for removed nodes
							const finalCursorPos = reAddTo - skipOffset;
							tr = tr.setSelection(TextSelection.create(tr.doc, finalCursorPos));
						}
					});

					// If we made any changes, apply them
					if (tr.steps.length > 0) {
						editor.view.dispatch(tr);
						
						return;
					}

					content.set(editor.getJSON());

					// Update content (object reference instead of using set method)
				} catch (e) {
					console.error(e);
				}
			}

initially the onUpdate was like

onUpdate: ({ editor, transaction }) => {
          try {
            const editorJson = editor.getJSON();
            editorJson.content = filterContent(editorJson.content); //filter empty nodes
            if (editorJson.content?.length === 0 && isParent) {
              editorJson.content.push({
                type: 'paragraph',
                content: [
                  {
                    type: 'heading',
                    text: '',
                    attrs: {
                      level: 1,
                      componentUUID: uuidv4()
                    }
                  }
                ]
              });
            }
            onUpdate(editorJson, editor, transaction.selection); //**this will trigger the respective node's onupdate function**
          } catch (e) {
            console.error('Error in track changes:', e);
          }
        }

now there are some bugs 1.When i enter into edit mode with lot of custom nodes in it,it goes on a recursive render 2.when i type something in custom node(note),new note node is inserted for everychange

can you please give me insights about tackling these,but my ultimate goal is to visualize diff between 2 versions of document(not as json,but in visually),or is there any better way?

I have no issue with basic editor,these problems arises with custom nodes,i just want to know is wrapping insertions/deletions is the best way to visualize differences between 2 versions and any possible reasons why

  1. new node is created on every change,
  2. infinite re-renders is there issue in this logic or will be the respective node’s onUpdate logic P.S → sharing onUpdate for NoteRenderer
  function updateDescription(val: object) {
    if (val && updateAttributes) updateAttributes({ ...node.attrs, text: val });
  }

the structure is different { type: ‘note’, attrs : { text: { type : ‘doc’, content : […], } } } i cant change this structure as its widely used already :smiling_face_with_tear:

Update : Resolved by handling it for custom nodes