From 89537aabc3365a4b001f9e220333833183280820 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 21 Sep 2023 08:44:23 -0400 Subject: [PATCH] Vendorize `prosemirror-recreate-transform` (#5861) --- .jestconfig.json | 9 +- package.json | 3 + .../test/{globalSetup.ts => globalSetup.js} | 2 +- .../{globalTeardown.ts => globalTeardown.js} | 0 .../prosemirror-recreate-transform/LICENSE | 201 +++++++++++++ .../lib/prosemirror-recreate-transform/NOTICE | 2 + .../prosemirror-recreate-transform/copy.ts | 3 + .../getFromPath.ts | 20 ++ .../getReplaceStep.ts | 31 ++ .../prosemirror-recreate-transform/index.ts | 3 + .../recreateTransform.ts | 284 ++++++++++++++++++ .../removeMarks.ts | 8 + .../simplifyTransform.ts | 30 ++ .../prosemirror-recreate-transform/types.ts | 10 + shared/editor/lib/uploadPlaceholder.tsx | 4 +- yarn.lock | 5 + 16 files changed, 606 insertions(+), 9 deletions(-) rename server/test/{globalSetup.ts => globalSetup.js} (89%) rename server/test/{globalTeardown.ts => globalTeardown.js} (100%) create mode 100644 shared/editor/lib/prosemirror-recreate-transform/LICENSE create mode 100644 shared/editor/lib/prosemirror-recreate-transform/NOTICE create mode 100644 shared/editor/lib/prosemirror-recreate-transform/copy.ts create mode 100644 shared/editor/lib/prosemirror-recreate-transform/getFromPath.ts create mode 100644 shared/editor/lib/prosemirror-recreate-transform/getReplaceStep.ts create mode 100644 shared/editor/lib/prosemirror-recreate-transform/index.ts create mode 100644 shared/editor/lib/prosemirror-recreate-transform/recreateTransform.ts create mode 100644 shared/editor/lib/prosemirror-recreate-transform/removeMarks.ts create mode 100644 shared/editor/lib/prosemirror-recreate-transform/simplifyTransform.ts create mode 100644 shared/editor/lib/prosemirror-recreate-transform/types.ts diff --git a/.jestconfig.json b/.jestconfig.json index eb8f132d0..89207fab6 100644 --- a/.jestconfig.json +++ b/.jestconfig.json @@ -9,13 +9,10 @@ "^@server/(.*)$": "/server/$1", "^@shared/(.*)$": "/shared/$1" }, - "setupFiles": [ - "/__mocks__/console.js", - "/server/test/env.ts" - ], + "setupFiles": ["/__mocks__/console.js", "/server/test/env.ts"], "setupFilesAfterEnv": ["/server/test/setup.ts"], - "globalSetup": "/server/test/globalSetup.ts", - "globalTeardown": "/server/test/globalTeardown.ts", + "globalSetup": "/server/test/globalSetup.js", + "globalTeardown": "/server/test/globalTeardown.js", "testEnvironment": "node" }, { diff --git a/package.json b/package.json index 9a3c64767..8142b992e 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "datadog-metrics": "^0.11.0", "date-fns": "^2.30.0", "dd-trace": "^3.33.0", + "diff": "^5.1.0", "dotenv": "^4.0.0", "email-providers": "^1.14.0", "emoji-mart": "^5.5.2", @@ -197,6 +198,7 @@ "reflect-metadata": "^0.1.13", "refractor": "^3.6.0", "request-filtering-agent": "^1.1.2", + "rfc6902": "^5.0.1", "sanitize-filename": "^1.6.3", "semver": "^7.5.2", "sequelize": "^6.32.1", @@ -240,6 +242,7 @@ "@types/addressparser": "^1.0.1", "@types/body-scroll-lock": "^3.1.0", "@types/crypto-js": "^4.1.1", + "@types/diff": "^5.0.4", "@types/emoji-regex": "^9.2.0", "@types/enzyme": "^3.10.13", "@types/enzyme-adapter-react-16": "^1.0.6", diff --git a/server/test/globalSetup.ts b/server/test/globalSetup.js similarity index 89% rename from server/test/globalSetup.ts rename to server/test/globalSetup.js index e3822dcd6..1e16b14f3 100644 --- a/server/test/globalSetup.ts +++ b/server/test/globalSetup.js @@ -5,7 +5,7 @@ module.exports = async function () { const sql = sequelize.getQueryInterface(); const tables = Object.keys(sequelize.models).map((model) => { const n = sequelize.models[model].getTableName(); - return (sql.queryGenerator as any).quoteTable( + return sql.queryGenerator.quoteTable( typeof n === "string" ? n : n.tableName ); }); diff --git a/server/test/globalTeardown.ts b/server/test/globalTeardown.js similarity index 100% rename from server/test/globalTeardown.ts rename to server/test/globalTeardown.js diff --git a/shared/editor/lib/prosemirror-recreate-transform/LICENSE b/shared/editor/lib/prosemirror-recreate-transform/LICENSE new file mode 100644 index 000000000..6f756351a --- /dev/null +++ b/shared/editor/lib/prosemirror-recreate-transform/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/shared/editor/lib/prosemirror-recreate-transform/NOTICE b/shared/editor/lib/prosemirror-recreate-transform/NOTICE new file mode 100644 index 000000000..762e66b26 --- /dev/null +++ b/shared/editor/lib/prosemirror-recreate-transform/NOTICE @@ -0,0 +1,2 @@ +prosemirror-recreate-steps +Copyright 2018 Atypon Systems, LLC. diff --git a/shared/editor/lib/prosemirror-recreate-transform/copy.ts b/shared/editor/lib/prosemirror-recreate-transform/copy.ts new file mode 100644 index 000000000..f17b880c7 --- /dev/null +++ b/shared/editor/lib/prosemirror-recreate-transform/copy.ts @@ -0,0 +1,3 @@ +export function copy(value: T): T { + return JSON.parse(JSON.stringify(value)); +} diff --git a/shared/editor/lib/prosemirror-recreate-transform/getFromPath.ts b/shared/editor/lib/prosemirror-recreate-transform/getFromPath.ts new file mode 100644 index 000000000..63d564e9f --- /dev/null +++ b/shared/editor/lib/prosemirror-recreate-transform/getFromPath.ts @@ -0,0 +1,20 @@ +import { JSONValue } from "./types"; + +/** + * get target value from json-pointer (e.g. /content/0/content) + * @param {AnyObject} obj object to resolve path into + * @param {string} path json-pointer + * @return {any} target value + */ +export function getFromPath(obj: JSONValue, path: string): JSONValue { + const pathParts = path.split("/"); + pathParts.shift(); // remove root-entry + while (pathParts.length) { + if (typeof obj !== "object") { + throw new Error(); + } + const property = pathParts.shift() as string; + obj = obj[property]; + } + return obj; +} diff --git a/shared/editor/lib/prosemirror-recreate-transform/getReplaceStep.ts b/shared/editor/lib/prosemirror-recreate-transform/getReplaceStep.ts new file mode 100644 index 000000000..5a49db2bb --- /dev/null +++ b/shared/editor/lib/prosemirror-recreate-transform/getReplaceStep.ts @@ -0,0 +1,31 @@ +import { Node } from "prosemirror-model"; +import { ReplaceStep } from "prosemirror-transform"; + +export function getReplaceStep(fromDoc: Node, toDoc: Node) { + let start = toDoc.content.findDiffStart(fromDoc.content); + if (start === null) { + return false; + } + + let { a: endA, b: endB } = toDoc.content.findDiffEnd(fromDoc.content) as { + a: number; + b: number; + }; + const overlap = start - Math.min(endA, endB); + if (overlap > 0) { + // If there is an overlap, there is some freedom of choice in how to calculate the + // start/end boundary. for an inserted/removed slice. We choose the extreme with + // the lowest depth value. + if ( + fromDoc.resolve(start - overlap).depth < + toDoc.resolve(endA + overlap).depth + ) { + start -= overlap; + } else { + endA += overlap; + endB += overlap; + } + } + + return new ReplaceStep(start, endB, toDoc.slice(start, endA)); +} diff --git a/shared/editor/lib/prosemirror-recreate-transform/index.ts b/shared/editor/lib/prosemirror-recreate-transform/index.ts new file mode 100644 index 000000000..aaa1ae081 --- /dev/null +++ b/shared/editor/lib/prosemirror-recreate-transform/index.ts @@ -0,0 +1,3 @@ +export { recreateTransform, RecreateTransform } from "./recreateTransform"; + +export type { Options } from "./recreateTransform"; diff --git a/shared/editor/lib/prosemirror-recreate-transform/recreateTransform.ts b/shared/editor/lib/prosemirror-recreate-transform/recreateTransform.ts new file mode 100644 index 000000000..6f29db3f8 --- /dev/null +++ b/shared/editor/lib/prosemirror-recreate-transform/recreateTransform.ts @@ -0,0 +1,284 @@ +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 === null) { + 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 (toDoc && 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 !== null) { + // @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(); +} diff --git a/shared/editor/lib/prosemirror-recreate-transform/removeMarks.ts b/shared/editor/lib/prosemirror-recreate-transform/removeMarks.ts new file mode 100644 index 000000000..febd40ca1 --- /dev/null +++ b/shared/editor/lib/prosemirror-recreate-transform/removeMarks.ts @@ -0,0 +1,8 @@ +import { Node } from "prosemirror-model"; +import { Transform } from "prosemirror-transform"; + +export function removeMarks(doc: Node) { + const tr = new Transform(doc); + tr.removeMark(0, doc.nodeSize - 2); + return tr.doc; +} diff --git a/shared/editor/lib/prosemirror-recreate-transform/simplifyTransform.ts b/shared/editor/lib/prosemirror-recreate-transform/simplifyTransform.ts new file mode 100644 index 000000000..af9790321 --- /dev/null +++ b/shared/editor/lib/prosemirror-recreate-transform/simplifyTransform.ts @@ -0,0 +1,30 @@ +import { Node } from "prosemirror-model"; +import { Transform, ReplaceStep, Step } from "prosemirror-transform"; +import { getReplaceStep } from "./getReplaceStep"; + +// join adjacent ReplaceSteps +export function simplifyTransform(tr: Transform) { + if (!tr.steps.length) { + return undefined; + } + + const newTr = new Transform(tr.docs[0]); + const oldSteps = tr.steps.slice(); + + while (oldSteps.length) { + let step = oldSteps.shift() as Step; + while (oldSteps.length && step.merge(oldSteps[0])) { + const addedStep = oldSteps.shift() as Step; + if (step instanceof ReplaceStep && addedStep instanceof ReplaceStep) { + step = getReplaceStep( + newTr.doc, + addedStep.apply(step.apply(newTr.doc).doc as Node).doc as Node + ) as Step; + } else { + step = step.merge(addedStep) as Step; + } + } + newTr.step(step); + } + return newTr; +} diff --git a/shared/editor/lib/prosemirror-recreate-transform/types.ts b/shared/editor/lib/prosemirror-recreate-transform/types.ts new file mode 100644 index 000000000..55f47200e --- /dev/null +++ b/shared/editor/lib/prosemirror-recreate-transform/types.ts @@ -0,0 +1,10 @@ +export interface JSONObject { + [p: string]: JSONValue; +} + +export type JSONValue = + | string + | number + | boolean + | JSONObject + | Array; diff --git a/shared/editor/lib/uploadPlaceholder.tsx b/shared/editor/lib/uploadPlaceholder.tsx index d49819f6f..b74746f5e 100644 --- a/shared/editor/lib/uploadPlaceholder.tsx +++ b/shared/editor/lib/uploadPlaceholder.tsx @@ -1,9 +1,9 @@ -import { recreateTransform } from "@fellow/prosemirror-recreate-transform"; import { EditorState, Plugin } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; import * as React from "react"; import ReactDOM from "react-dom"; import FileExtension from "../components/FileExtension"; +import { recreateTransform } from "../lib/prosemirror-recreate-transform"; // based on the example at: https://prosemirror.net/examples/upload/ const uploadPlaceholder = new Plugin({ @@ -19,7 +19,7 @@ const uploadPlaceholder = new Plugin({ wordDiffs: false, simplifyDiff: true, }).mapping; - return set.map(mapping, tr.doc); + set = set.map(mapping, tr.doc); } else { set = set.map(tr.mapping, tr.doc); } diff --git a/yarn.lock b/yarn.lock index dffed29df..589259c78 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2852,6 +2852,11 @@ dependencies: "@types/ms" "*" +"@types/diff@^5.0.4": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@types/diff/-/diff-5.0.4.tgz#ba774c225ee68ce13a090fec16cf34b97a78537b" + integrity sha512-d7489/WO4B65k0SIqxXtviR9+MrPDipWQF6w+5D7YPrqgu6Qb87JsTdWQaNZo7itcdbViQSev3Jaz7dtKO0+Dg== + "@types/emoji-regex@^9.2.0": version "9.2.0" resolved "https://registry.yarnpkg.com/@types/emoji-regex/-/emoji-regex-9.2.0.tgz#2e117de04f5fa561c5dcbe43a860ecd856517525"