Implement new track changes in current document, just lice office review mode

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:

  1. IME input
  2. skip ‘insertion’ when readding the deleted content
  3. 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);
      }
    }
  }
}

1 Like

Did you fix the issue mentioned in Track Changes w/ marks

I find that it makes undo history a mess. You can undo forever after you input any text.

yes, but I implement in a new framework named tiptap extension. here you can take a view and check the code in my repo.

Track Change Extension for Tiptap (track-change.onrender.com)

chenyuncai/tiptap-track-change-extension (github.com)

Add a filter-in-tr to ignore history change can fix your problem.

1 Like

Hi @chenyunaci …Me facing an issue based on this extension .

Sample text on editor:

Heading Text

Lorem Ipsumis simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry’s standard dummy text ever since the 1500s, when an unknown printer took a galley of type.

Issue: if we click beyond the heading and select the heading and paragraph completely then click delete . It results a console error " Uncaught TransformError: Inconsistent open depths"…Also the content was removed from editor without adding deletion mark .

This issue also exist while we select two paragraphs for delete.