Question about track-changes with prosemirror-changeset

Hey, so as I’ve been figuring out how to implement track-changes I stumbled upon prosemirror-changeset as somewhat easy way to implement insertions and deletions. However, the API doesn’t provide answers on how to implement such feature and I wasn’t able to find previous solutions that would have worked out of the box.

The insertions were quite straightforward to implement yet it’s the deletions that cause a bit of head scratching. I managed to get the deleted content using the previous editor state but the positions do not map correctly as I edit the document.

My code:

const deletedWidget = (span: Span) => (view: EditorView, getPos: () => number) => {
  const element = document.createElement('span')
  element.textContent = span.data
  element.classList.add('deleted')
  return element
}

function spanData(tr: Transaction, oldState: EditorState) {
  return tr.steps.map(s => {
    if (s instanceof ReplaceStep) {
      // @ts-ignore
      const noContent = s.slice.size === 0
      if (noContent) {
        // @ts-ignore
        return oldState.doc.textBetween(s.from, s.to)
      }
      // @ts-ignore
      return s.slice.content.textBetween(0, s.slice.size)
    }
  })
}

export interface PluginState {
  changeSet: ChangeSet
  decorationSet: DecorationSet<ManuscriptSchema>
}

export const diffPlugin = () =>
  new Plugin({
    key: diffPluginKey,
    state: {
      init(config, state) {
        return {
          changeSet: ChangeSet.create(state.doc),
          decorationSet: DecorationSet.empty,
        }
      },
      apply(tr, value, oldState, newState) {
        const changeSet = value.changeSet.addSteps(tr.doc, tr.mapping.maps, spanData(tr, oldState, newState))
        const decorations: Decoration[] = []

        changeSet.changes.forEach((change) => {
          let insertFrom = change.fromB
          change.inserted.forEach((span) => {
            decorations.push(Decoration.inline(insertFrom, change.toB, { class: 'inserted' }))
            // @ts-ignore
            insertFrom += span.length
          })

          let deletedFrom = change.fromA
          change.deleted.forEach((span) => {
            decorations.push(
              Decoration.widget(deletedFrom, deletedWidget(span), {
                side: -1,
                marks: [schema.marks.strikethrough.create()],
              })
            )
            // @ts-ignore
            deletedFrom += span.length
          })
        })

        return {
          changeSet,
          decorationSet: DecorationSet.create(tr.doc, decorations)
        }
      },
    },
    props: {
      decorations(state) {
        return this.getState(state).decorationSet
      },
    },
  })

You’ll note a few ts-ignores where the type definitions don’t match the API reference. Nevertheless, I am unsure what to do to keep the positions for the deleted content updated. Should I map the changes somehow? I use quite simplistic approach to retrieve the deleted content so I wonder should I do that differently too?

After that I also have to figure out how to combine the adjacent changes. Hopefully this post will serve as a future reference how to do this all properly.

Teemu

1 Like

I’m not sure what that means. But so far, setups like this that I’ve seen use a deleted range’s fromA/toA to read deleted data from the original document (the one that the change set started with), rather than tracking it in the span data field.

I made an example here https://teemukoivisto.github.io/prosemirror-track-changes-example/ You can refresh the page to reset the decorations (the doc is persisted). I guess I just have a misconception how the changes work. I did try to derive the deletions from the startDoc too but I guess as the positions were bit off also then, I revised this approach.

changeSet.startDoc.textBetween(change.fromA, change.fromA + span.length)

This seems to place all spans at the start of the change. I think you’ll need to track a position (starting at change.fromA) as you iterate over the spans.

Hey, thanks for the reply. Hmm, interesting. Yes there definitely is something funny going on when I write adjacent insertions and deletions and edit them. This could be why.

And I actually fixed my previous bugs where I messed the deletion positions. I did not understand to take into account the insertions and deletions before said deletion, which I now keep track in higher-scoped counters. Then another issue I found was that adjacent deletions would not behave nicely, perhaps due to my decorations not being configured right. I had to make this little function to combine them into one:

const joinChanges = (changes: Span[]) => changes.reduce((acc, cur, idx) => {
  if (idx === 0) return [cur]
  return Span.join([cur], acc, (a: any, b: any) => {
    // Should combine the metadatas, usernames and timestamps et cetera
    return b + a
  })
}, [] as Span[])

Yet in the end I think this approach as a whole is bit too sophisticated for generic track-changes and I’ll probably work out something simpler. Diffing seems nice but in reality, having some characters pop out as unchanged in the middle of replacing a string is maybe more confusing than helpful. I think it’s better to just have a sequence of insertions or deletions which users can revert and tinker with. Also to show changed marks I guess you kinda have to make something custom. CKEditor has quite nice implementation. But thanks anyway.