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.

Hey, I was looking into prosemirror-changeset and it got me wondering how to map user metadata with the changes. Is there any way this can be done?

Yes each Span has was it data payload that can contain arbitrary data. Then you can define your own method for joining spans to merge them properly.

@marijn @TeemuKoivisto Is there a way to persist changeset? Maybe in a DB in JSON format?

Not currently, I think. The typical approach is to persist steps and rebuild changesets on-demand.