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