import { diffWordsWithSpace, diffChars, Change } from "diff"; import { Node, Schema } from "prosemirror-model"; import { Transform } from "prosemirror-transform"; import { applyPatch, createPatch, Operation } from "rfc6902"; import { ReplaceOperation } from "rfc6902/diff"; import { copy } from "./copy"; import { getFromPath } from "./getFromPath"; import { getReplaceStep } from "./getReplaceStep"; import { removeMarks } from "./removeMarks"; import { simplifyTransform } from "./simplifyTransform"; import { JSONObject } from "./types"; export interface Options { complexSteps?: boolean; wordDiffs?: boolean; simplifyDiff?: boolean; } export class RecreateTransform { fromDoc: Node; toDoc: Node; complexSteps: boolean; wordDiffs: boolean; simplifyDiff: boolean; schema: Schema; tr: Transform; /* current working document data, may get updated while recalculating node steps */ currentJSON: JSONObject; /* final document as json data */ finalJSON: JSONObject; ops: Array; constructor(fromDoc: Node, toDoc: Node, options: Options = {}) { const o = { complexSteps: true, wordDiffs: false, simplifyDiff: true, ...options, }; this.fromDoc = fromDoc; this.toDoc = toDoc; this.complexSteps = o.complexSteps; // Whether to return steps other than ReplaceSteps this.wordDiffs = o.wordDiffs; // Whether to make text diffs cover entire words this.simplifyDiff = o.simplifyDiff; this.schema = fromDoc.type.schema; this.tr = new Transform(fromDoc); this.currentJSON = {}; this.finalJSON = {}; this.ops = []; } init() { if (this.complexSteps) { // For First steps: we create versions of the documents without marks as // these will only confuse the diffing mechanism and marks won't cause // any mapping changes anyway. this.currentJSON = removeMarks(this.fromDoc).toJSON(); this.finalJSON = removeMarks(this.toDoc).toJSON(); this.ops = createPatch(this.currentJSON, this.finalJSON); this.recreateChangeContentSteps(); this.recreateChangeMarkSteps(); } else { // We don't differentiate between mark changes and other changes. this.currentJSON = this.fromDoc.toJSON(); this.finalJSON = this.toDoc.toJSON(); this.ops = createPatch(this.currentJSON, this.finalJSON); this.recreateChangeContentSteps(); } if (this.simplifyDiff) { this.tr = simplifyTransform(this.tr) || this.tr; } return this.tr; } /** convert json-diff to prosemirror steps */ recreateChangeContentSteps() { // First step: find content changing steps. let ops = []; while (this.ops.length) { // get next let op = this.ops.shift() as Operation; ops.push(op); let toDoc; const afterStepJSON = copy(this.currentJSON); // working document receiving patches const pathParts = op.path.split("/"); // collect operations until we receive a valid document: // apply ops-patches until a valid prosemirror document is retrieved, // then try to create a transformation step or retry with next operation while (!toDoc) { applyPatch(afterStepJSON, [op]); try { toDoc = this.schema.nodeFromJSON(afterStepJSON); toDoc.check(); } catch (error) { toDoc = null; if (this.ops.length > 0) { op = this.ops.shift() as Operation; ops.push(op); } else { throw new Error(`No valid diff possible applying ${op.path}`); } } } // apply operation (ignoring afterStepJSON) if ( this.complexSteps && ops.length === 1 && (pathParts.includes("attrs") || pathParts.includes("type")) ) { // Node markup is changing this.addSetNodeMarkup(); // a lost update is ignored ops = []; } else if ( ops.length === 1 && op.op === "replace" && pathParts[pathParts.length - 1] === "text" ) { // Text is being replaced, we apply text diffing to find the smallest possible diffs. this.addReplaceTextSteps(op, afterStepJSON); ops = []; } else if (this.addReplaceStep(toDoc, afterStepJSON)) { // operations have been applied ops = []; } } } /** update node with attrs and marks, may also change type */ addSetNodeMarkup() { // first diff in document is supposed to be a node-change (in type and/or attributes) // thus simply find the first change and apply a node change step, then recalculate the diff // after updating the document const fromDoc = this.schema.nodeFromJSON(this.currentJSON); const toDoc = this.schema.nodeFromJSON(this.finalJSON); const start = toDoc.content.findDiffStart(fromDoc.content) as number; // @note start is the same (first) position for current and target document const fromNode = fromDoc.nodeAt(start) as Node; const toNode = toDoc.nodeAt(start) as Node; if (!start) { // @note this completly updates all attributes in one step, by completely replacing node const nodeType = fromNode.type === toNode.type ? null : toNode.type; try { this.tr.setNodeMarkup(start, nodeType, toNode.attrs, toNode.marks); } catch (e) { // if nodetypes differ, the updated node-type and contents might not be compatible // with schema and requires a replace if (nodeType && (e as Error).message.includes("Invalid content")) { // @todo add test-case for this scenario this.tr.replaceWith(start, start + fromNode.nodeSize, toNode); } else { throw e; } } this.currentJSON = removeMarks(this.tr.doc).toJSON(); // setting the node markup may have invalidated the following ops, so we calculate them again. this.ops = createPatch(this.currentJSON, this.finalJSON); return true; } return false; } recreateChangeMarkSteps() { // Now the documents should be the same, except their marks, so everything should map 1:1. // Second step: Iterate through the toDoc and make sure all marks are the same in tr.doc this.toDoc.descendants((tNode, tPos) => { if (!tNode.isInline) { return true; } this.tr.doc.nodesBetween(tPos, tPos + tNode.nodeSize, (fNode, fPos) => { if (!fNode.isInline) { return true; } const from = Math.max(tPos, fPos); const to = Math.min(tPos + tNode.nodeSize, fPos + fNode.nodeSize); fNode.marks.forEach((nodeMark) => { if (!nodeMark.isInSet(tNode.marks)) { this.tr.removeMark(from, to, nodeMark); } }); tNode.marks.forEach((nodeMark) => { if (!nodeMark.isInSet(fNode.marks)) { this.tr.addMark(from, to, nodeMark); } }); return; }); return; }); } /** * retrieve and possibly apply replace-step based from doc changes * From http://prosemirror.net/examples/footnote/ */ addReplaceStep(toDoc: Node, afterStepJSON: JSONObject) { const fromDoc = this.schema.nodeFromJSON(this.currentJSON); const step = getReplaceStep(fromDoc, toDoc); if (!step) { return false; } else if (!this.tr.maybeStep(step).failed) { this.currentJSON = afterStepJSON; return true; // @change previously null } throw new Error("No valid step found."); } /** retrieve and possibly apply text replace-steps based from doc changes */ addReplaceTextSteps(op: ReplaceOperation, afterStepJSON: JSONObject) { // We find the position number of the first character in the string const op1 = { ...op, value: "xx" }; const op2 = { ...op, value: "yy" }; const afterOP1JSON = copy(this.currentJSON); const afterOP2JSON = copy(this.currentJSON); applyPatch(afterOP1JSON, [op1]); applyPatch(afterOP2JSON, [op2]); const op1Doc = this.schema.nodeFromJSON(afterOP1JSON); const op2Doc = this.schema.nodeFromJSON(afterOP2JSON); // get text diffs const finalText = op.value; const currentText = getFromPath(this.currentJSON, op.path) as string; const textDiffs = this.wordDiffs ? diffWordsWithSpace(currentText, finalText) : diffChars(currentText, finalText); let offset = op1Doc.content.findDiffStart(op2Doc.content) as number; const marks = op1Doc.resolve(offset + 1).marks(); while (textDiffs.length) { const diff = textDiffs.shift() as Change; if (diff.added) { const textNode = this.schema .nodeFromJSON({ type: "text", text: diff.value }) .mark(marks); if (textDiffs.length && textDiffs[0].removed) { const nextDiff = textDiffs.shift() as Change; this.tr.replaceWith(offset, offset + nextDiff.value.length, textNode); } else { this.tr.insert(offset, textNode); } offset += diff.value.length; } else if (diff.removed) { if (textDiffs.length && textDiffs[0].added) { const nextDiff = textDiffs.shift() as Change; const textNode = this.schema .nodeFromJSON({ type: "text", text: nextDiff.value }) .mark(marks); this.tr.replaceWith(offset, offset + diff.value.length, textNode); offset += nextDiff.value.length; } else { this.tr.delete(offset, offset + diff.value.length); } } else { offset += diff.value.length; } } this.currentJSON = afterStepJSON; } } export function recreateTransform( fromDoc: Node, toDoc: Node, options: Options = {} ): Transform { const recreator = new RecreateTransform(fromDoc, toDoc, options); return recreator.init(); }