The offical track change example is only record the commitor.
https://prosemirror.net/examples/track/
and we found a topic about real track changes in Track Changes w/ marks. it is very great. and I upgrade the code to more features:
- IME input
- skip ‘insertion’ when readding the deleted content
- reset insertion and deletion mark when generate new marks
below are new main code, you need a index.html and require-pm.js in his origin code in hit glitch
const {
EditorState,
TextSelection,
} = require("prosemirror-state");
const { EditorView } = require("prosemirror-view");
const { Schema, DOMParser, Slice } = require("prosemirror-model");
const { schema } = require("prosemirror-schema-basic");
const { addListNodes } = require("prosemirror-schema-list");
const { ReplaceStep, Step } = require("prosemirror-transform");
const { exampleSetup } = require("prosemirror-example-setup");
// Mix the nodes from prosemirror-schema-list into the basic schema to
// create a schema with list support.
const mySchema = new Schema({
nodes: addListNodes(schema.spec.nodes, "paragraph block*", "block", "b", "i"),
marks: {
...schema.spec.marks,
deletion: {
toDOM() {
return ["del", 0];
},
parseDOM: [{ tag: "del" }],
},
insertion: {
toDOM() {
return ["ins", 0];
},
parseDOM: [{ tag: "ins" }],
},
},
});
const initialState = EditorState.create({
doc: DOMParser.fromSchema(mySchema).parse(document.querySelector("#content")),
plugins: exampleSetup({ schema: mySchema }),
});
let editorState = initialState;
new EditorView(document.querySelector("#editor"), {
state: editorState,
dispatchTransaction: function (tr) {
// the last state
let currentState = editorState;
editorState = editorState.apply(tr);
this.updateState(editorState);
// input status check
const isChineseStart = !this._lastComposing && this.composing
const isChineseEnd = this._lastComposing && !this.composing
const isChineseInputing = !isChineseStart && !isChineseEnd && this.composing
const isNormalInput = !isChineseStart && !isChineseEnd && !isChineseInputing
if (tr.steps.length) {
/**
* once composing start, the last confirm action get a composing true too
* if just input one char and no other input with split chars, editor will not get new confirm action
* IME confirm will delete the char and readd the selected char, so we get two user actions
* one we replace the selected char with composing input, we need correct the cursor to new selection, but when we do that, IME cannot remove the old losing chars, so we need to delete it manually
*/
console.log('\r\n\r\n\r\nchange begin')
console.table({
composing: this.composing,
isNormalInput,
isChineseStart,
isChineseInputing,
isChineseEnd
})
// the latest commit
const newCommit = {
id: 1,
steps: tr.steps.map((step) => step.toJSON()),
};
// get the new cursor position after the user action
const currentPos = tr.selection.from;
let posOffset = 0;
// when the chars changed by our track plugin, we need to change the last selection to new pos
let hasAddAndDelete = false
tr.steps.forEach((step) => {
// delete chars in this step
const delCount = step.to - step.from;
// added chars in this step
const newCount = step.slice ? step.slice.size : 0;
// only sum the deleted chars
// TODO: but how to detect delete the 'delete' key pressed? need another way
posOffset += delCount;
if (newCount && delCount) {
// if only add or del, don't need to change selection, it's already right
hasAddAndDelete = true;
}
});
if (isNormalInput) {
if (!hasAddAndDelete) {
posOffset = 0
}
} else if (isChineseStart) {
if (hasAddAndDelete) {
posOffset -= 1
} else {
posOffset = 0
}
} else if (isChineseEnd) {
posOffset = 0
} else if (isChineseInputing) {
posOffset = 0
}
// the new epos
const newPos = currentPos + posOffset;
console.table({currentPos, posOffset, newPos})
// gen the track change marks
const blame = new Blame(currentState, [newCommit]);
// update state and the doc will change, and the gen the text selection related to this newDoc
this.updateState(blame.state);
const trWithChange = this.state.tr;
trWithChange.setSelection(TextSelection.create(this.state.doc, newPos));
const stateWithNewSelection = this.state.apply(trWithChange);
this.updateState(stateWithNewSelection);
// save the latest editor state
if (isChineseStart && hasAddAndDelete) {
const tr = this.state.tr
tr.step(new ReplaceStep(newPos, newPos + 1, Slice.empty))
const stateWithDeleteLastChar = this.state.apply(tr)
this.updateState(stateWithDeleteLastChar)
}
editorState = this.state
}
this._lastComposing = this.composing
},
});
class Rebaseable {
constructor(step, inverted, origin) {
this.step = step;
this.inverted = inverted;
this.origin = origin;
}
}
// : ([Rebaseable], [Step], Transform) → [Rebaseable]
// Undo a given set of steps, apply a set of other steps, and then
// redo them.
function rebaseSteps(steps, over, transform) {
for (let i = steps.length - 1; i >= 0; i--) transform.step(steps[i].inverted);
for (let i = 0; i < over.length; i++) transform.step(over[i]);
let result = [];
for (let i = 0, mapFrom = steps.length; i < steps.length; i++) {
let mapped = steps[i].step.map(transform.mapping.slice(mapFrom));
mapFrom--;
if (mapped && !transform.maybeStep(mapped).failed) {
transform.mapping.setMirror(mapFrom, transform.steps.length - 1);
result.push(
new Rebaseable(
mapped,
mapped.invert(transform.docs[transform.docs.length - 1]),
steps[i].origin
)
);
}
}
return result;
}
function unconfirmedFrom(transform) {
let result = [];
for (let i = 0; i < transform.steps.length; i++)
result.push(
new Rebaseable(
transform.steps[i],
transform.steps[i].invert(transform.docs[i]),
transform
)
);
return result;
}
class Blame {
constructor(initialState, commits) {
this.commits = commits;
// Convert commits to steps
let originalTr = initialState.tr;
for (const commit of commits) {
for (const json of commit.steps) {
const step = Step.fromJSON(mySchema, json);
originalTr.step(step);
}
}
let originalSteps = unconfirmedFrom(originalTr);
// Make the commits
this.state = initialState;
while (originalSteps.length) {
const originalStep = originalSteps.shift();
const { step } = originalStep;
const originalStepTr = this.state.tr;
originalStepTr.step(step);
this.state = this.state.apply(originalStepTr);
const trackTr = this.state.tr;
if (step instanceof ReplaceStep) {
// Mark the insert, if any
if (step.slice.size) {
const insertionMark = this.state.doc.type.schema.marks.insertion.create();
const deletionMark = this.state.doc.type.schema.marks.deletion.create();
const { from } = step;
const to = from + step.slice.size;
trackTr.addMark(from, to, insertionMark);
// remove the deletion mark to avoid the default del style
trackTr.removeMark(from, to, deletionMark);
}
// Re-add/mark the delete, if any
if (step.from !== step.to) {
const invertedStep = step.invert(originalStepTr.docs[0]);
/**
* if the chars is has 'insert' mark, then just ignore the delete action
* so we need to recognize the insert mark in invertedStep's slice
*/
console.log('invertedStep', invertedStep);
/**
* a step includes multiple content, how can I found the insertion mark to skip?
* we can scan the step content and gen a new step with Slice.empty to delete them
*/
const deleteSteps = []
let offset = 0
invertedStep.slice.content.content.forEach(content => {
const start = invertedStep.from + offset
const end = start + content.nodeSize
offset += content.nodeSize
if (content.marks.find(m => m.type.name === 'insertion')) {
deleteSteps.push(new ReplaceStep(start, end, Slice.empty))
}
})
const deleteStep = new ReplaceStep(
invertedStep.from,
invertedStep.from,
invertedStep.slice,
invertedStep.structure
);
// readd the deleted content
trackTr.step(deleteStep);
const { from } = deleteStep;
const to = from + deleteStep.slice.size;
// add delete mark on the reAdded content
const deletionMark = this.state.doc.type.schema.marks.deletion.create();
trackTr.addMark(from, to, deletionMark);
deleteSteps.forEach(step => {
// delete the content if it is already with insertion mark
trackTr.step(step)
})
}
}
if (trackTr.steps.length) {
originalSteps = rebaseSteps(
originalSteps,
trackTr.steps,
this.state.tr
);
this.state = this.state.apply(trackTr);
}
}
}
}