import { defineDefaultState, definePlugin, jsonFromNode, union, type NodeJSON, type PlainExtension, type StepJSON } from '@prosekit/core'
import { DOMSerializer, Fragment, Slice, type ProseMirrorNode } from '@prosekit/pm/model'
import { PluginKey, ProseMirrorPlugin, type Transaction } from '@prosekit/pm/state'
import { Step } from '@prosekit/pm/transform'
import { Decoration, DecorationSet, type EditorView } from '@prosekit/pm/view'
import { ChangeSet, type Change } from 'prosemirror-changeset'
/**
* A JSON representation of a commit.
*/
interface Commit {
/**
* The current doc node in the JSON format
*/
doc: NodeJSON
/**
* The parent node in the JSON format
*/
parent: NodeJSON
/**
* An array of steps in the JSON format that transform the parent node to the
* current doc node.
*/
steps: StepJSON[]
}
function getChanges(
doc: ProseMirrorNode,
parent: ProseMirrorNode,
steps: Step[],
): readonly Change[] {
const initSet = ChangeSet.create(parent)
const currSet = initSet.addSteps(
doc,
steps.map((step) => step.getMap()),
null,
)
return currSet.changes
}
function renderDivWeight(view: EditorView): HTMLElement {
const document = view.dom.ownerDocument
return document.createElement('div')
}
function decorateDeletionSlice(
slice: Slice,
): Array<(view: EditorView) => HTMLElement> {
// Get the fragment of the deleted content
let { openStart, openEnd, content } = slice
while (openStart > 0 && openEnd > 0 && content.childCount === 1) {
openStart--
openEnd--
content = content.child(0).content
}
// Nothing to render
if (content.childCount === 0) {
return []
}
// For example, if the slice is
// {
// openStart: 1,
// openEnd: 1,
// content:
Hello
World
// }
// We should render the following decorations:
// Hello
//
// World
if (openStart > 0 && openEnd > 0 && content.childCount === 2) {
const head = Fragment.from([content.child(0)])
const tail = Fragment.from([content.child(1)])
return [
...decorateDeletionSlice(new Slice(head, openStart, openStart)),
renderDivWeight,
...decorateDeletionSlice(new Slice(tail, openEnd, openEnd)),
]
}
// For example, if the slice is
// {
// openStart: 1,
// openEnd: 0,
// content: Hello
World
// }
// We should render the following decorations:
// Hello
//
if (openStart > 0 && content.childCount >= 2) {
const nodes = content.content
const head = Fragment.from(nodes.slice(0, 1))
const body = Fragment.from(nodes.slice(1))
return [
...decorateDeletionSlice(new Slice(head, openStart, openStart)),
...decorateDeletionSlice(new Slice(body, 0, openEnd)),
]
}
// For example, if the slice is
// {
// openStart: 0,
// openEnd: 1,
// content: Hello
World
// }
// We should render the following decorations:
//
// World
if (openEnd > 0 && content.childCount >= 2) {
const nodes = content.content
const body = Fragment.from(nodes.slice(0, -1))
const tail = Fragment.from(nodes.slice(-1))
return [
...decorateDeletionSlice(new Slice(body, openStart, 0)),
...decorateDeletionSlice(new Slice(tail, openEnd, openEnd)),
]
}
const schema = content.child(0).type.schema
const isInline = content.child(0).isInline
const render = (view: EditorView): HTMLElement => {
const document = view.dom.ownerDocument
// Render the fragment to HTML
const element = document.createElement(isInline ? 'span' : 'div')
const serializer = DOMSerializer.fromSchema(schema)
serializer.serializeFragment(content, { document }, element)
// Add the class to the element
element.classList.add('prosekit-commit-deletion')
return element
}
return [render]
}
function decorateDeletion(
/** The doc node before the deletion */
doc: ProseMirrorNode,
/** The start position of the deleted text in the doc node */
from: number,
/** The end position of the deleted text in the doc node */
to: number,
/** The insert position of the decoration in the doc node after the change */
pos: number,
): Decoration[] {
const slice = doc.slice(from, to)
const renders = decorateDeletionSlice(slice)
const count = renders.length
return renders.map((render, index) =>
Decoration.widget(pos, render, {
side: -20 - count + index,
// Ensure the text in the decoration is able to be selected.
ignoreSelection: true,
})
)
}
function decorateAddition(
/** The start position of the inserted text in the doc node */
from: number,
/** The end position of the inserted text in the doc node */
to: number,
): Decoration {
return Decoration.inline(from, to, { class: 'prosekit-commit-addition' })
}
function decorateChange(prev: ProseMirrorNode, change: Change): Decoration[] {
const { fromA, toA, fromB, toB } = change
const decorations: Decoration[] = []
if (fromA < toA) {
decorations.push(...decorateDeletion(prev, fromA, toA, fromB))
}
if (fromB < toB) {
decorations.push(decorateAddition(fromB, toB))
}
return decorations
}
function decorateCommit(
doc: ProseMirrorNode,
parent: ProseMirrorNode,
steps: Step[],
): DecorationSet {
const changes = getChanges(doc, parent, steps)
const decorations = changes.flatMap((change) => decorateChange(parent, change))
return DecorationSet.create(doc, decorations)
}
function defineCommitDecoration(commit: Commit): PlainExtension {
const key = new PluginKey('prosekit-commit-decoration')
return definePlugin(({ schema }): ProseMirrorPlugin => {
const parent = schema.nodeFromJSON(commit.parent)
const steps = commit.steps.map((step) => Step.fromJSON(schema, step))
return new ProseMirrorPlugin({
key,
state: {
init: (_, instance): DecorationSet => {
return decorateCommit(instance.doc, parent, steps)
},
apply: (tr, deco: DecorationSet): DecorationSet => {
return deco.map(tr.mapping, tr.doc)
},
},
props: {
decorations: (state): DecorationSet | undefined => {
return key.getState(state)
},
},
})
})
}
/**
* Define an extension to display the changes from the given commit in the editor.
*/
function defineCommitViewer(commit: Commit): PlainExtension {
return union(
defineDefaultState({ defaultContent: commit.doc }),
defineCommitDecoration(commit),
)
}
class CommitRecorder {
private parent: ProseMirrorNode | null = null
private doc: ProseMirrorNode | null = null
private steps: Step[] = []
/**
* Return a commit object including all changes since the last commit. `null`
* will be returned if there is no change.
*/
commit(): Commit | null {
if (
!this.parent
|| !this.doc
|| this.steps.length === 0
|| this.parent.eq(this.doc)
) {
return null
}
const commit: Commit = {
doc: jsonFromNode(this.doc),
parent: jsonFromNode(this.parent),
steps: this.steps.map((step) => step.toJSON() as StepJSON),
}
this.init(this.doc)
return commit
}
/**
* @internal
*/
init(doc: ProseMirrorNode): void {
this.doc = doc
this.parent = doc
this.steps = []
}
/**
* @internal
*/
apply(tr: Transaction): void {
this.steps.push(...tr.steps)
this.doc = tr.doc
}
}
/**
* Define an extension that can record the changes in the editor.
*/
function defineCommitRecorder(commitRecorder: CommitRecorder): PlainExtension {
const key = new PluginKey('prosekit-commit-recorder')
return definePlugin(
new ProseMirrorPlugin({
key,
state: {
init: (_, state): void => {
commitRecorder.init(state.doc)
},
apply: (tr): void => {
commitRecorder.apply(tr)
},
},
}),
)
}
export { CommitRecorder, defineCommitRecorder, defineCommitViewer, type Commit }