+ );
+}
diff --git a/apps/www/src/registry/registry.ts b/apps/www/src/registry/registry.ts
index 8464b42ba5..9367d5dbb1 100644
--- a/apps/www/src/registry/registry.ts
+++ b/apps/www/src/registry/registry.ts
@@ -695,6 +695,12 @@ const example: Registry = [
registryDependencies: [],
files: ['example/multiple-editors-demo.tsx'],
},
+ {
+ name: 'version-history-demo',
+ type: 'components:example',
+ registryDependencies: [],
+ files: ['example/version-history-demo.tsx'],
+ },
{
name: 'playground-demo',
type: 'components:example',
diff --git a/config/eslint/bases/unicorn.cjs b/config/eslint/bases/unicorn.cjs
index 609cfcd929..93838bb201 100644
--- a/config/eslint/bases/unicorn.cjs
+++ b/config/eslint/bases/unicorn.cjs
@@ -2,6 +2,7 @@ module.exports = {
extends: ['plugin:unicorn/recommended'],
plugins: ['unicorn'],
rules: {
+ 'unicorn/no-abusive-eslint-disable': 'off',
'unicorn/prefer-module': 'off',
'unicorn/consistent-destructuring': 'off',
'unicorn/consistent-function-scoping': [
diff --git a/package.json b/package.json
index bcafca94a7..13e39626a1 100644
--- a/package.json
+++ b/package.json
@@ -49,8 +49,8 @@
"g:typecheck:apps": "turbo --filter=www typecheck --no-daemon",
"install:playwright": "yarn playwright install --with-deps",
"nuke:node_modules": "rimraf '**/node_modules'",
- "p:brl": "cd $INIT_CWD && barrelsby -d $INIT_CWD/src -D -l all -q -e '.*(fixture|template|spec|__tests__).*'",
- "p:brl:below": "cd $INIT_CWD && barrelsby -d $INIT_CWD/src -D -l below -q -e '.*(fixture|template|spec|__tests__).*'",
+ "p:brl": "cd $INIT_CWD && barrelsby -d $INIT_CWD/src -D -l all -q -e '.*(fixture|template|spec|internal).*'",
+ "p:brl:below": "cd $INIT_CWD && barrelsby -d $INIT_CWD/src -D -l below -q -e '.*(fixture|template|spec|internal).*'",
"p:build": "cd $INIT_CWD && yarn p:tsup",
"p:build:watch": "cd $INIT_CWD && yarn p:tsup --watch",
"p:clean": "cd $INIT_CWD && rimraf dist && jest --clear-cache",
diff --git a/packages/suggestion/.npmignore b/packages/suggestion/.npmignore
index 7d3b305b17..c55d56e7e5 100644
--- a/packages/suggestion/.npmignore
+++ b/packages/suggestion/.npmignore
@@ -1,3 +1,3 @@
-__tests__
+src/diff/internal
__test-utils__
__mocks__
diff --git a/packages/suggestion/LICENSE b/packages/suggestion/LICENSE
new file mode 100644
index 0000000000..734e6ba6b2
--- /dev/null
+++ b/packages/suggestion/LICENSE
@@ -0,0 +1,255 @@
+# License
+
+Changes introduced by "@udecode/plate-diff" are licensed under the following MIT License:
+
+> The MIT License (MIT)
+>
+> Copyright (c) Ziad Beyens, Dylan Schiemann, Joe Anderson
+>
+> Unless otherwise specified in a LICENSE file within an individual package directory,
+> this license applies to all files in this repository outside of those package directories.
+>
+> Permission is hereby granted, free of charge, to any person obtaining a copy
+> of this software and associated documentation files (the "Software"), to deal
+> in the Software without restriction, including without limitation the rights
+> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+> copies of the Software, and to permit persons to whom the Software is
+> furnished to do so, subject to the following conditions:
+>
+> The above copyright notice and this permission notice shall be included in
+> all copies or substantial portions of the Software.
+>
+> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+> THE SOFTWARE.
+
+Please use the Git version history to identify changes introduced by "@udecode/plate-diff".
+
+## slate-diff
+
+`slate-diff` is taken from https://github.com/pubuzhixing8/slate-diff, used under the following Apache License 2.0:
+
+> 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-match-patch library
+
+`@udecode/plate-diff` is itself a derivative work of "diff-match-patch" by Neil Fraser (https://github.com/GerHobbelt/google-diff-match-patch), used under the following Apache License, Version 2.0:
+
+> Copyright 2006 Google Inc.
+> http://code.google.com/p/google-diff-match-patch/
+>
+> 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.
\ No newline at end of file
diff --git a/packages/suggestion/package.json b/packages/suggestion/package.json
index b18fcfa008..17e3de9ce1 100644
--- a/packages/suggestion/package.json
+++ b/packages/suggestion/package.json
@@ -2,7 +2,6 @@
"name": "@udecode/plate-suggestion",
"version": "30.1.2",
"description": "Plate plugin for suggestions",
- "license": "MIT",
"homepage": "https://platejs.org",
"repository": {
"type": "git",
@@ -39,7 +38,9 @@
"typecheck": "yarn p:typecheck"
},
"dependencies": {
- "@udecode/plate-common": "30.1.2"
+ "@udecode/plate-common": "30.1.2",
+ "diff-match-patch-ts": "0.3.0",
+ "lodash": "^4.17.21"
},
"peerDependencies": {
"react": ">=16.8.0",
diff --git a/packages/suggestion/src/diff-to-suggestions/applyDiffToSuggestions.fixtures.ts b/packages/suggestion/src/diff-to-suggestions/applyDiffToSuggestions.fixtures.ts
new file mode 100644
index 0000000000..7696a114d0
--- /dev/null
+++ b/packages/suggestion/src/diff-to-suggestions/applyDiffToSuggestions.fixtures.ts
@@ -0,0 +1,718 @@
+import { TOperation } from '@udecode/plate-common';
+
+export const addMarkFixtures = {
+ doc1: [
+ {
+ type: 'paragraph',
+ children: [{ text: 'PingCode Wiki & Worktile' }],
+ },
+ ],
+ doc2: [
+ {
+ type: 'paragraph',
+ children: [
+ { text: 'PingCode ' },
+ {
+ text: 'Wiki',
+ bold: true,
+ suggestion: true,
+ suggestion_0: true,
+ suggestionId: '1',
+ suggestionUpdate: {
+ bold: true,
+ },
+ },
+ {
+ text: ' & Worktile',
+ // TODO
+ bold: undefined,
+ },
+ ],
+ },
+ ],
+ operations: [
+ {
+ path: [0, 0],
+ position: 9,
+ properties: { bold: true },
+ type: 'split_node',
+ },
+ {
+ path: [0, 1],
+ position: 4,
+ properties: {
+ bold: undefined,
+ },
+ type: 'split_node',
+ },
+ ] as TOperation[],
+};
+
+export const addTwoMarkFixtures = {
+ doc1: [
+ {
+ type: 'paragraph',
+ children: [{ text: 'These words are bold!' }],
+ },
+ ],
+ doc2: [
+ {
+ type: 'paragraph',
+ children: [
+ { text: 'These ' },
+ {
+ text: 'words',
+ bold: true,
+ suggestion: true,
+ suggestion_0: true,
+ suggestionId: '1',
+ suggestionUpdate: {
+ bold: true,
+ },
+ },
+ {
+ text: ' are ',
+ },
+ {
+ text: 'bold',
+ bold: true,
+ suggestion: true,
+ suggestion_0: true,
+ suggestionId: '1',
+ suggestionUpdate: {
+ bold: true,
+ },
+ },
+ {
+ text: '!',
+ },
+ ],
+ },
+ ],
+ operations: [
+ {
+ type: 'split_node',
+ path: [0, 0],
+ position: 6,
+ properties: {
+ bold: true,
+ },
+ },
+ {
+ type: 'split_node',
+ path: [0, 1],
+ position: 5,
+ properties: {},
+ },
+ {
+ type: 'split_node',
+ path: [0, 2],
+ position: 5,
+ properties: {
+ bold: true,
+ },
+ },
+ {
+ type: 'split_node',
+ path: [0, 3],
+ position: 4,
+ properties: {},
+ },
+ ] as TOperation[],
+};
+
+export const insertUpdateParagraphFixtures = {
+ doc1: [
+ {
+ type: 'paragraph',
+ children: [{ text: 'This is the first paragraph.' }],
+ key: '1',
+ },
+ {
+ type: 'paragraph',
+ children: [{ text: 'This is the third paragraph.' }],
+ key: '3',
+ },
+ {
+ type: 'paragraph',
+ children: [{ text: 'This is the fourth paragraph.' }],
+ key: '4',
+ },
+ ],
+ doc2: [
+ {
+ type: 'paragraph',
+ children: [{ text: 'This is the first paragraph.' }],
+ key: '1',
+ },
+ {
+ type: 'paragraph',
+ children: [{ text: 'This is the second paragraph.' }],
+ key: '2',
+ suggestion: true,
+ suggestion_0: true,
+ suggestionId: '1',
+ },
+ {
+ type: 'paragraph',
+ children: [
+ { text: 'This is the third paragraph' },
+ {
+ text: ', and insert some text',
+ suggestion: true,
+ suggestion_0: true,
+ suggestionId: '1',
+ },
+ {
+ text: '.',
+ },
+ ],
+ key: '3',
+ },
+ {
+ type: 'paragraph',
+ children: [{ text: 'This is the fourth paragraph.' }],
+ key: '4',
+ },
+ ],
+ operations: [
+ {
+ node: {
+ children: [{ text: 'This is the second paragraph.' }],
+ key: '2',
+ type: 'paragraph',
+ },
+ path: [1],
+ type: 'insert_node',
+ },
+ {
+ offset: 27,
+ path: [2, 0],
+ text: ', and insert some text',
+ type: 'insert_text',
+ },
+ ] as TOperation[],
+};
+export const insertUpdateTwoParagraphsFixtures = {
+ doc1: [
+ {
+ type: 'paragraph',
+ children: [{ text: 'This is the first paragraph.' }],
+ key: '1',
+ },
+ {
+ type: 'paragraph',
+ children: [{ text: 'This is the third paragraph.' }],
+ key: '3',
+ },
+ {
+ type: 'paragraph',
+ children: [{ text: 'This is the fourth paragraph.' }],
+ key: '4',
+ },
+ ],
+ doc2: [
+ {
+ type: 'paragraph',
+ children: [{ text: 'This is the first paragraph.' }],
+ key: '1',
+ },
+ {
+ type: 'paragraph',
+ children: [{ text: 'This is the second paragraph.' }],
+ key: '2',
+ suggestion: true,
+ suggestion_0: true,
+ suggestionId: '1',
+ },
+ {
+ type: 'paragraph',
+ children: [
+ { text: 'This is the third paragraph' },
+ {
+ text: ', and insert some text',
+ suggestion: true,
+ suggestion_0: true,
+ suggestionId: '1',
+ },
+ {
+ text: '.',
+ },
+ ],
+ key: '3',
+ },
+ {
+ type: 'paragraph',
+ children: [{ text: 'This is the fifth paragraph.' }],
+ key: '5',
+ suggestion: true,
+ suggestion_0: true,
+ suggestionId: '1',
+ },
+ {
+ type: 'paragraph',
+ children: [
+ { text: 'This is the fourth paragraph' },
+ {
+ text: ', and insert some text',
+ suggestion: true,
+ suggestion_0: true,
+ suggestionId: '1',
+ },
+ {
+ text: '.',
+ },
+ ],
+ key: '4',
+ },
+ ],
+ operations: [
+ {
+ node: {
+ children: [{ text: 'This is the second paragraph.' }],
+ key: '2',
+ type: 'paragraph',
+ },
+ path: [1],
+ type: 'insert_node',
+ },
+ {
+ offset: 27,
+ path: [2, 0],
+ text: ', and insert some text',
+ type: 'insert_text',
+ },
+ {
+ node: {
+ children: [{ text: 'This is the fifth paragraph.' }],
+ key: '5',
+ type: 'paragraph',
+ },
+ path: [3],
+ type: 'insert_node',
+ },
+ {
+ offset: 28,
+ path: [4, 0],
+ text: ', and insert some text',
+ type: 'insert_text',
+ },
+ ] as TOperation[],
+};
+export const insertTextAddMarkFixtures = {
+ doc1: [
+ {
+ type: 'paragraph',
+ children: [{ text: 'PingCode' }],
+ },
+ ],
+ doc2: [
+ {
+ type: 'paragraph',
+ children: [
+ {
+ text: 'PingCode',
+ bold: true,
+ },
+ {
+ text: ' & ',
+ bold: undefined,
+ suggestion: true,
+ suggestion_0: true,
+ suggestionId: '1',
+ suggestionUpdate: {
+ bold: undefined,
+ },
+ },
+ {
+ text: 'Worktile',
+ bold: true,
+ suggestion: true,
+ suggestion_0: true,
+ suggestionId: '1',
+ },
+ ],
+ },
+ ],
+ operations: [
+ {
+ type: 'insert_text',
+ path: [0, 0],
+ offset: 8,
+ text: ' & Worktile',
+ },
+ {
+ type: 'set_node',
+ path: [0, 0],
+ properties: {},
+ newProperties: {
+ bold: true,
+ },
+ },
+ {
+ type: 'split_node',
+ path: [0, 0],
+ position: 8,
+ properties: {
+ bold: undefined,
+ },
+ },
+ {
+ type: 'split_node',
+ path: [0, 1],
+ position: 3,
+ properties: {
+ bold: true,
+ },
+ },
+ ] as TOperation[],
+};
+
+export const insertTextFixtures = {
+ doc1: [
+ {
+ type: 'paragraph',
+ children: [{ text: 'PingCode' }],
+ },
+ ],
+ doc2: [
+ {
+ type: 'paragraph',
+ children: [
+ { text: 'PingCode' },
+ {
+ text: ' & Worktile',
+ suggestion: true,
+ suggestion_0: true,
+ suggestionId: '1',
+ },
+ ],
+ },
+ ],
+ operations: [
+ {
+ type: 'insert_text',
+ path: [0, 0],
+ offset: 8,
+ text: ' & Worktile',
+ },
+ ] as TOperation[],
+};
+
+export const removeNodeFixtures = {
+ doc1: [
+ {
+ type: 'paragraph',
+ children: [{ text: 'PingCode' }],
+ },
+ {
+ type: 'paragraph',
+ children: [{ text: 'Worktile' }],
+ },
+ ],
+ doc2: [
+ {
+ type: 'paragraph',
+ children: [{ text: 'PingCode' }],
+ },
+ {
+ type: 'paragraph',
+ children: [{ text: 'Worktile' }],
+ suggestion: true,
+ suggestion_0: true,
+ suggestionId: '1',
+ suggestionDeletion: true,
+ },
+ ],
+ operations: [
+ {
+ type: 'remove_node',
+ path: [1],
+ node: {
+ type: 'paragraph',
+ children: [{ text: 'Worktile' }],
+ },
+ },
+ ] as TOperation[],
+};
+
+export const removeTextFixtures = {
+ doc1: [
+ {
+ type: 'paragraph',
+ children: [{ text: 'PingCode & Worktile' }],
+ },
+ ],
+ doc2: [
+ {
+ type: 'paragraph',
+ children: [
+ { text: 'PingCode' },
+ {
+ text: ' & Worktile',
+ suggestion: true,
+ suggestion_0: true,
+ suggestionId: '1',
+ suggestionDeletion: true,
+ },
+ ],
+ },
+ ],
+ operations: [
+ {
+ type: 'remove_text',
+ path: [0, 0],
+ offset: 8,
+ text: ' & Worktile',
+ },
+ ] as TOperation[],
+};
+
+export const mergeTextFixtures = {
+ doc1: [
+ {
+ type: 'paragraph',
+ children: [
+ {
+ text: 'PingCode',
+ bold: true,
+ },
+ {
+ text: ' & ',
+ },
+ ],
+ },
+ ],
+ doc2: [
+ {
+ type: 'paragraph',
+ children: [
+ {
+ text: 'PingCode',
+ bold: true,
+ },
+ {
+ text: ' & ',
+ bold: true,
+ suggestion: true,
+ suggestion_0: true,
+ suggestionId: '1',
+ suggestionUpdate: {
+ bold: true,
+ },
+ },
+ ],
+ },
+ ],
+ operations: [
+ {
+ type: 'merge_node',
+ path: [0, 1],
+ position: 0,
+ properties: {},
+ },
+ ] as TOperation[],
+};
+
+export const mergeNodeFixtures = {
+ doc1: [
+ {
+ type: 'paragraph',
+ children: [
+ {
+ text: 'PingCode',
+ bold: true,
+ },
+ ],
+ },
+ {
+ type: 'paragraph',
+ children: [
+ {
+ text: ' & ',
+ },
+ {
+ text: 'co',
+ bold: true,
+ },
+ ],
+ },
+ ],
+ doc2: [
+ {
+ type: 'paragraph',
+ children: [
+ {
+ text: 'PingCode',
+ bold: true,
+ },
+ {
+ text: ' & ',
+ suggestion: true,
+ suggestion_0: true,
+ suggestionId: '1',
+ },
+ {
+ text: 'co',
+ bold: true,
+ suggestion: true,
+ suggestion_0: true,
+ suggestionId: '1',
+ },
+ ],
+ },
+ {
+ type: 'paragraph',
+ children: [
+ {
+ text: ' & ',
+ },
+ {
+ text: 'co',
+ bold: true,
+ },
+ ],
+ suggestion: true,
+ suggestion_0: true,
+ suggestionId: '1',
+ suggestionDeletion: true,
+ },
+ ],
+ operations: [
+ {
+ type: 'merge_node',
+ path: [1],
+ position: 0,
+ properties: {},
+ },
+ ] as TOperation[],
+};
+
+export const mergeTwoTextFixtures = {
+ doc1: [
+ {
+ type: 'paragraph',
+ children: [
+ {
+ text: 'PingCode',
+ bold: true,
+ },
+ {
+ text: ' & ',
+ },
+ {
+ text: 'Worktile',
+ bold: true,
+ },
+ ],
+ },
+ ],
+ doc2: [
+ {
+ type: 'paragraph',
+ children: [
+ {
+ text: 'PingCode',
+ bold: true,
+ },
+ {
+ text: ' & ',
+ bold: true,
+ suggestion: true,
+ suggestion_0: true,
+ suggestionId: '1',
+ suggestionUpdate: {
+ bold: true,
+ },
+ },
+ {
+ text: 'Worktile',
+ bold: true,
+ },
+ ],
+ },
+ ],
+ operations: [
+ {
+ type: 'merge_node',
+ path: [0, 1],
+ position: 0,
+ properties: {},
+ },
+ {
+ type: 'merge_node',
+ path: [0, 1],
+ position: 0,
+ properties: {},
+ },
+ ] as TOperation[],
+};
+
+export const mergeRemoveTextFixtures = {
+ doc1: [
+ {
+ type: 'paragraph',
+ children: [
+ {
+ text: 'PingCode',
+ bold: true,
+ },
+ {
+ text: ' & ',
+ },
+ {
+ text: 'Worktile',
+ bold: true,
+ },
+ ],
+ },
+ ],
+ doc2: [
+ {
+ type: 'paragraph',
+ children: [
+ {
+ text: 'PingCode',
+ },
+ {
+ text: ' & Worktile',
+ bold: true,
+ suggestion: true,
+ suggestion_0: true,
+ suggestionId: '1',
+ suggestionDeletion: true,
+ },
+ ],
+ },
+ ],
+ operations: [
+ {
+ type: 'merge_node',
+ path: [0, 1],
+ position: 0,
+ properties: {},
+ },
+ {
+ type: 'merge_node',
+ path: [0, 1],
+ position: 0,
+ properties: {},
+ },
+ {
+ type: 'remove_text',
+ path: [0, 0],
+ offset: 8,
+ text: ' & Worktile',
+ },
+ {
+ type: 'set_node',
+ path: [0, 0],
+ properties: {
+ bold: true,
+ },
+ newProperties: {
+ bold: undefined,
+ },
+ },
+ ] as TOperation[],
+};
diff --git a/packages/suggestion/src/diff-to-suggestions/applyDiffToSuggestions.test.ts b/packages/suggestion/src/diff-to-suggestions/applyDiffToSuggestions.test.ts
new file mode 100644
index 0000000000..812971a216
--- /dev/null
+++ b/packages/suggestion/src/diff-to-suggestions/applyDiffToSuggestions.test.ts
@@ -0,0 +1,112 @@
+import { createPlateEditor } from '@udecode/plate-common';
+
+import { applyDiffToSuggestions } from './applyDiffToSuggestions';
+import {
+ addMarkFixtures,
+ addTwoMarkFixtures,
+ insertTextAddMarkFixtures,
+ insertTextFixtures,
+ insertUpdateParagraphFixtures,
+ insertUpdateTwoParagraphsFixtures,
+ mergeNodeFixtures,
+ mergeRemoveTextFixtures,
+ mergeTextFixtures,
+ mergeTwoTextFixtures,
+ removeNodeFixtures,
+ removeTextFixtures,
+} from './applyDiffToSuggestions.fixtures';
+
+describe('slate-diff', () => {
+ let editor: any;
+ const options = { idFactory: () => '1' };
+
+ beforeEach(() => {
+ editor = createPlateEditor();
+ });
+
+ it('insert-text', () => {
+ editor.children = insertTextFixtures.doc1;
+ applyDiffToSuggestions(editor, insertTextFixtures.operations, options);
+ expect(editor.children).toStrictEqual(insertTextFixtures.doc2);
+ });
+
+ it('remove-node', () => {
+ editor.children = removeNodeFixtures.doc1;
+ applyDiffToSuggestions(editor, removeNodeFixtures.operations, options);
+ expect(editor.children).toStrictEqual(removeNodeFixtures.doc2);
+ });
+
+ it('remove-text', () => {
+ editor.children = removeTextFixtures.doc1;
+ applyDiffToSuggestions(editor, removeTextFixtures.operations, options);
+ expect(editor.children).toStrictEqual(removeTextFixtures.doc2);
+ });
+
+ it('add-mark', () => {
+ editor.children = addMarkFixtures.doc1;
+ applyDiffToSuggestions(editor, addMarkFixtures.operations, options);
+ expect(editor.children).toStrictEqual(addMarkFixtures.doc2);
+ });
+
+ it('add-two-mark', () => {
+ editor.children = addTwoMarkFixtures.doc1;
+ applyDiffToSuggestions(editor, addTwoMarkFixtures.operations, options);
+ expect(editor.children).toStrictEqual(addTwoMarkFixtures.doc2);
+ });
+
+ it('insert-text-and-add-mark', () => {
+ editor.children = insertTextAddMarkFixtures.doc1;
+ applyDiffToSuggestions(
+ editor,
+ insertTextAddMarkFixtures.operations,
+ options
+ );
+ expect(editor.children).toStrictEqual(insertTextAddMarkFixtures.doc2);
+ });
+
+ it('merge-text', () => {
+ editor.children = mergeTextFixtures.doc1;
+ applyDiffToSuggestions(editor, mergeTextFixtures.operations, options);
+ expect(editor.children).toStrictEqual(mergeTextFixtures.doc2);
+ });
+
+ it('merge-two-text', () => {
+ editor.children = mergeTwoTextFixtures.doc1;
+ applyDiffToSuggestions(editor, mergeTwoTextFixtures.operations, options);
+ expect(editor.children).toStrictEqual(mergeTwoTextFixtures.doc2);
+ });
+
+ it('merge-node', () => {
+ editor.children = mergeNodeFixtures.doc1;
+ applyDiffToSuggestions(editor, mergeNodeFixtures.operations, options);
+ expect(editor.children).toStrictEqual(mergeNodeFixtures.doc2);
+ });
+
+ it('merge-remove-text', () => {
+ editor.children = mergeRemoveTextFixtures.doc1;
+ applyDiffToSuggestions(editor, mergeRemoveTextFixtures.operations, options);
+ expect(editor.children).toStrictEqual(mergeRemoveTextFixtures.doc2);
+ });
+
+ it('insert-and-update-paragraph', () => {
+ editor.children = insertUpdateParagraphFixtures.doc1;
+ applyDiffToSuggestions(
+ editor,
+ insertUpdateParagraphFixtures.operations,
+ options
+ );
+ expect(editor.children).toStrictEqual(insertUpdateParagraphFixtures.doc2);
+ });
+
+ it('insert-and-update-two-paragraphs', () => {
+ editor.children = insertUpdateTwoParagraphsFixtures.doc1;
+ applyDiffToSuggestions(
+ editor,
+ insertUpdateTwoParagraphsFixtures.operations,
+ options
+ );
+ expect(editor.children).toStrictEqual(
+ insertUpdateTwoParagraphsFixtures.doc2
+ );
+ });
+});
diff --git a/packages/suggestion/src/diff-to-suggestions/applyDiffToSuggestions.ts b/packages/suggestion/src/diff-to-suggestions/applyDiffToSuggestions.ts
new file mode 100644
index 0000000000..0ad0e6dd89
--- /dev/null
+++ b/packages/suggestion/src/diff-to-suggestions/applyDiffToSuggestions.ts
@@ -0,0 +1,578 @@
+import {
+ addRangeMarks,
+ createPathRef,
+ createPointRef,
+ createRangeRef,
+ getEndPoint,
+ getFragment,
+ getNode,
+ getNodeProps,
+ getPreviousPath,
+ getStartPoint,
+ insertFragment,
+ insertNodes,
+ isText,
+ nanoid,
+ PlateEditor,
+ setNodes,
+ TDescendant,
+ TInsertNodeOperation,
+ TInsertTextOperation,
+ TMergeNodeOperation,
+ TOperation,
+ TRemoveNodeOperation,
+ TRemoveTextOperation,
+ TSplitNodeOperation,
+ withoutNormalizing,
+} from '@udecode/plate-common';
+import isEqual from 'lodash/isEqual.js';
+import uniqWith from 'lodash/uniqWith.js';
+import { PathRef, Point, PointRef, Range, RangeRef } from 'slate';
+
+import { getSuggestionProps } from '../transforms';
+
+const objectWithoutUndefined = (obj: Record) => {
+ const newObj: Record = {};
+
+ Object.keys(obj).forEach((key) => {
+ if (obj[key] !== undefined) {
+ newObj[key] = obj[key];
+ }
+ });
+
+ return newObj;
+};
+
+const addPropsToTextsInFragment = (
+ fragment: TDescendant[],
+ props: any
+): TDescendant[] => {
+ return fragment.map((node) => {
+ if (isText(node)) {
+ return {
+ ...node,
+ ...props,
+ };
+ }
+
+ return {
+ ...node,
+ children: addPropsToTextsInFragment(node.children, props),
+ };
+ });
+};
+
+type InsertedNode = {
+ pathRef: PathRef;
+};
+
+type InsertedRange = {
+ rangeRef: RangeRef;
+};
+
+type RemovedNodes = {
+ locationRef: PathRef | PointRef;
+ nodes: TDescendant[];
+ isFragment: boolean;
+};
+
+type UpdatedProperties = {
+ rangeRef: RangeRef;
+ properties: Record;
+ newProperties: Record;
+};
+
+let insertedNodes: InsertedNode[] = [];
+let insertedRanges: InsertedRange[] = [];
+let removedNodes: RemovedNodes[] = [];
+let updatedProperties: UpdatedProperties[] = [];
+
+const handleUpdatedProperties = () => {
+ // console.log('updatedProperties', JSON.stringify(updatedProperties.map(({ rangeRef, ...rest }) => ({ range: rangeRef.current, ...rest })), null, 2));
+
+ const unsortedRangePoints = updatedProperties.flatMap(({ rangeRef }) => {
+ const range = rangeRef.current;
+ if (!range) return [];
+ return [range.anchor, range.focus];
+ });
+
+ const rangePoints = uniqWith(
+ unsortedRangePoints.sort(Point.compare),
+ Point.equals
+ );
+ if (rangePoints.length < 2) return [];
+
+ const flatRanges = Array.from({ length: rangePoints.length - 1 })
+ .fill(null)
+ .map((_, i) => ({
+ anchor: rangePoints[i],
+ focus: rangePoints[i + 1],
+ }));
+
+ // console.log('flatRanges', JSON.stringify(flatRanges, null, 2));
+
+ const flatUpdates = flatRanges.map((flatRange, i) => {
+ const debug = i == 1;
+
+ const intersectingUpdates = updatedProperties.filter(({ rangeRef }) => {
+ const range = rangeRef.current;
+ if (!range) return false;
+ const intersection = Range.intersection(range, flatRange);
+ if (!intersection) return false;
+ return Range.isExpanded(intersection);
+ });
+
+ if (debug) {
+ // console.log('flatRange', flatRange);
+ // console.log('intersectingUpdates', intersectingUpdates);
+ }
+
+ if (intersectingUpdates.length === 0) return null;
+
+ const initialProps = objectWithoutUndefined(
+ intersectingUpdates[0].properties
+ );
+
+ // const finalProps = intersectingUpdates.reduce((props, { newProperties }) => ({
+ // ...props,
+ // ...newProperties,
+ // }), initialProps);
+
+ const finalProps = objectWithoutUndefined(
+ intersectingUpdates.at(-1)!.newProperties
+ );
+
+ if (isEqual(initialProps, finalProps)) return null;
+
+ if (debug) {
+ // console.log('initialProps', initialProps);
+ // console.log('finalProps', finalProps);
+ }
+
+ const diffProps: Record = {};
+
+ Object.keys(finalProps).forEach((key) => {
+ if (initialProps[key] !== finalProps[key]) {
+ diffProps[key] = finalProps[key];
+ }
+ });
+
+ Object.keys(initialProps).forEach((key) => {
+ if (!(key in finalProps)) {
+ diffProps[key] = undefined;
+ }
+ });
+
+ return {
+ range: flatRange,
+ diffProps,
+ };
+ });
+
+ updatedProperties.forEach(({ rangeRef }) => {
+ rangeRef.unref();
+ });
+
+ return flatUpdates.filter(Boolean) as Exclude<
+ (typeof flatUpdates)[number],
+ null
+ >[];
+};
+
+const insertTextSuggestion = (
+ editor: PlateEditor,
+ op: TInsertTextOperation
+) => {
+ const anchor = { path: op.path, offset: op.offset };
+ editor.apply(op);
+ const focus = { path: op.path, offset: op.offset + op.text.length };
+ const rangeRef = createRangeRef(editor, { anchor, focus });
+ insertedRanges.push({ rangeRef });
+ // const text = op.text;
+ // const id = idFactory();
+
+ // const target = getNode(editor, op.path);
+
+ // insertNodes(
+ // editor,
+ // {
+ // ...target,
+ // text,
+ // ...getSuggestionProps(editor, id),
+ // },
+ // {
+ // at: {
+ // path: op.path,
+ // offset: op.offset,
+ // },
+ // }
+ // );
+
+ // Assume selection is collapsed
+};
+
+export const insertNodeSuggestion = (
+ editor: PlateEditor,
+ op: TInsertNodeOperation
+) => {
+ editor.apply(op);
+ const pathRef = createPathRef(editor, op.path);
+ insertedNodes.push({ pathRef });
+};
+
+export const mergeNodeSuggestion = (
+ editor: PlateEditor,
+ op: TMergeNodeOperation
+) => {
+ const { path } = op;
+
+ const node = getNode(editor, path);
+ if (!node) return;
+
+ const prevPath = getPreviousPath(path);
+ if (!prevPath) return;
+
+ const prev = getNode(editor, prevPath);
+ if (!prev) return;
+
+ // Get the range of merged children
+ const endOfPrev = getEndPoint(editor, prevPath);
+ editor.apply(op);
+ const endOfMerged = getEndPoint(editor, prevPath);
+
+ const mergedRange = {
+ anchor: endOfPrev,
+ focus: endOfMerged,
+ };
+
+ const mergedRangeRef = createRangeRef(editor, mergedRange);
+
+ const nodeProps = getNodeProps(node);
+ const prevProps = getNodeProps(prev);
+ // const propsEqual = true; // isEqual(nodeProps, prevProps);
+
+ // Element case
+ if (!isText(node) || !isText(prev)) {
+ insertedRanges.push({ rangeRef: mergedRangeRef });
+
+ removedNodes.push({
+ locationRef: createPointRef(editor, endOfMerged),
+ nodes: [node],
+ isFragment: false,
+ });
+ return;
+ }
+
+ // Text case
+ updatedProperties.push({
+ rangeRef: mergedRangeRef,
+ properties: nodeProps,
+ newProperties: prevProps,
+ });
+};
+
+export const splitNodeSuggestion = (
+ editor: PlateEditor,
+ op: TSplitNodeOperation
+) => {
+ const { path } = op;
+
+ const node = getNode(editor, path);
+ if (!node) return;
+
+ const nodeProps = getNodeProps(node);
+
+ editor.apply(op);
+
+ const nextPath = [...path.slice(0, -1), path.at(-1)! + 1];
+
+ const range = {
+ anchor: getStartPoint(editor, nextPath),
+ focus: getEndPoint(editor, nextPath),
+ };
+
+ const rangeRef = createRangeRef(editor, range);
+
+ updatedProperties.push({
+ rangeRef,
+ properties: nodeProps,
+ newProperties: op.properties,
+ });
+};
+
+// export const mergeNodeSuggestion = (
+// editor: PlateEditor,
+// op: TMergeNodeOperation,
+// {
+// idFactory,
+// }: {
+// idFactory: () => string;
+// }
+// ) => {
+// const { path } = op;
+// const node = getNode(editor, path);
+//
+// if (!node) return;
+//
+// const prevPath = getPreviousPath(path);
+// if (!prevPath) return;
+//
+// const prev = getNode(editor, prevPath);
+// if (!prev) return;
+//
+// const parent = getNodeParent(editor, path);
+// if (!parent) return;
+//
+// if (isText(node) && isText(prev)) {
+// removeTextSuggestion(
+// editor,
+// {
+// type: 'remove_text',
+// path: path,
+// offset: 0,
+// text: node.text,
+// },
+// { idFactory }
+// );
+//
+// insertNodeSuggestion(
+// editor,
+// {
+// type: 'insert_node',
+// node: {
+// ...prev,
+// text: node.text,
+// },
+// path: Path.next(path),
+// },
+// { idFactory }
+// );
+// } else if (!isText(node) && !isText(prev)) {
+// let index = prev.children.length;
+// node.children.forEach((child) => {
+// insertNodeSuggestion(
+// editor,
+// {
+// type: 'insert_node',
+// node: child,
+// path: prevPath.concat([index]),
+// },
+// { idFactory }
+// );
+// index += 1;
+// });
+//
+// removeNodeSuggestion(
+// editor,
+// {
+// type: 'remove_node',
+// path,
+// node,
+// },
+// { idFactory }
+// );
+// } else {
+// return;
+// }
+// };
+
+export const removeTextSuggestion = (
+ editor: PlateEditor,
+ op: TRemoveTextOperation
+) => {
+ const range = {
+ anchor: { path: op.path, offset: op.offset },
+ focus: { path: op.path, offset: op.offset + op.text.length },
+ };
+ const fragment = getFragment(editor, range);
+ editor.apply(op);
+ const pointRef = createPointRef(editor, range.anchor);
+ removedNodes.push({
+ locationRef: pointRef,
+ nodes: fragment,
+ isFragment: true,
+ });
+ // const id = idFactory();
+
+ // addRangeMarks(
+ // editor,
+ // getSuggestionProps(editor, id, { suggestionDeletion: true }),
+ // {
+ // at: {
+ // anchor: {
+ // path: op.path,
+ // offset: op.offset,
+ // },
+ // focus: {
+ // path: op.path,
+ // offset: op.offset + op.text.length,
+ // },
+ // },
+ // }
+ // );
+};
+
+export const removeNodeSuggestion = (
+ editor: PlateEditor,
+ op: TRemoveNodeOperation
+) => {
+ // const pointBefore = getPointBefore(editor, op.path);
+ // const locationRef = pointBefore
+ // ? createPointRef(editor, pointBefore)
+ // : createPathRef(editor, op.path);
+
+ // console.log('removeNodeSuggestion', op.path, pointBefore, locationRef.current);
+
+ editor.apply(op);
+
+ let nodes = [op.node];
+
+ /**
+ * If the current remove invalidated the PathRef of any previous remove,
+ * insert the previous remove's nodes into the current remove's node list.
+ */
+ removedNodes.forEach((removedNodeEntry) => {
+ const { locationRef, nodes: oldNodes } = removedNodeEntry;
+ if (locationRef.current === null) {
+ nodes = [...oldNodes, ...nodes];
+ removedNodeEntry.nodes = [];
+ }
+ });
+
+ const locationRef = createPathRef(editor, op.path);
+ removedNodes.push({ locationRef, nodes, isFragment: false });
+};
+
+export const applyDiffToSuggestions = (
+ editor: PlateEditor,
+ diffOperations: TOperation[],
+ {
+ idFactory = nanoid,
+ }: {
+ idFactory?: () => string;
+ } = {}
+) => {
+ withoutNormalizing(editor, () => {
+ diffOperations.forEach((op) => {
+ switch (op.type) {
+ case 'insert_text': {
+ insertTextSuggestion(editor, op);
+ return;
+ }
+ case 'remove_text': {
+ removeTextSuggestion(editor, op);
+ return;
+ }
+ case 'insert_node': {
+ insertNodeSuggestion(editor, op);
+ return;
+ }
+ case 'remove_node': {
+ removeNodeSuggestion(editor, op);
+ return;
+ }
+ case 'merge_node': {
+ mergeNodeSuggestion(editor, op);
+ return;
+ }
+ case 'split_node': {
+ splitNodeSuggestion(editor, op);
+ return;
+ }
+ case 'set_node': {
+ editor.apply(op);
+ return;
+ }
+ case 'move_node': {
+ // never
+ editor.apply(op);
+ return;
+ }
+ case 'set_selection': {
+ // never
+ editor.apply(op);
+ return;
+ }
+ // No default
+ }
+ });
+
+ insertedNodes.forEach(({ pathRef }) => {
+ const path = pathRef.current;
+ if (path) {
+ const node = getNode(editor, path);
+ if (node) {
+ setNodes(editor, getSuggestionProps(editor, idFactory()), {
+ at: path,
+ });
+ }
+ }
+ });
+
+ insertedRanges.forEach(({ rangeRef }) => {
+ const range = rangeRef.current;
+ if (range) {
+ addRangeMarks(editor, getSuggestionProps(editor, idFactory()), {
+ at: range,
+ });
+ }
+ rangeRef.unref();
+ });
+
+ removedNodes.forEach(({ locationRef, nodes, isFragment }) => {
+ const location = locationRef.current;
+ // console.log({ location, nodes, isFragment });
+ if (location) {
+ const suggestionProps = getSuggestionProps(editor, idFactory(), {
+ suggestionDeletion: true,
+ });
+
+ if (isFragment) {
+ const fragmentWithSuggestion = addPropsToTextsInFragment(
+ nodes,
+ suggestionProps
+ );
+
+ // console.log('fragmentWithSuggestion', fragmentWithSuggestion);
+
+ insertFragment(editor, fragmentWithSuggestion, {
+ at: location,
+ });
+ } else {
+ const nodesWithSuggestion = nodes.map((node) => ({
+ ...node,
+ ...suggestionProps,
+ }));
+
+ insertNodes(editor, nodesWithSuggestion, {
+ at: location,
+ });
+ }
+ }
+ locationRef.unref();
+ });
+
+ // Reverse the array to prevent path changes
+ const flatUpdates = handleUpdatedProperties().reverse();
+
+ // console.log('flatUpdates', JSON.stringify(flatUpdates, null, 2));
+
+ flatUpdates.forEach(({ range, diffProps }) => {
+ addRangeMarks(
+ editor,
+ {
+ ...getSuggestionProps(editor, idFactory()),
+ suggestionUpdate: diffProps,
+ },
+ { at: range }
+ );
+ });
+ });
+
+ insertedNodes = [];
+ insertedRanges = [];
+ removedNodes = [];
+ updatedProperties = [];
+
+ // console.log(JSON.stringify(editor.children, null, 2));
+};
diff --git a/packages/suggestion/src/diff-to-suggestions/getSuggestionNode.ts b/packages/suggestion/src/diff-to-suggestions/getSuggestionNode.ts
new file mode 100644
index 0000000000..538ae02ee4
--- /dev/null
+++ b/packages/suggestion/src/diff-to-suggestions/getSuggestionNode.ts
@@ -0,0 +1,22 @@
+import { TDescendant } from '@udecode/plate-common';
+
+export const getSuggestionNode = (
+ node: TDescendant,
+ {
+ deletion,
+ }: {
+ deletion?: boolean;
+ } = {}
+) => {
+ const nextNode: TDescendant = {
+ ...node,
+ suggestion: true,
+ suggestionId: '1',
+ suggestion_0: true,
+ };
+ if (deletion) {
+ nextNode.suggestionDeletion = true;
+ }
+
+ return nextNode;
+};
diff --git a/packages/suggestion/src/diff-to-suggestions/index.ts b/packages/suggestion/src/diff-to-suggestions/index.ts
new file mode 100644
index 0000000000..45002d14b5
--- /dev/null
+++ b/packages/suggestion/src/diff-to-suggestions/index.ts
@@ -0,0 +1,7 @@
+/**
+ * @file Automatically generated by barrelsby.
+ */
+
+export * from './applyDiffToSuggestions.test';
+export * from './applyDiffToSuggestions';
+export * from './getSuggestionNode';
diff --git a/packages/suggestion/src/index.ts b/packages/suggestion/src/index.ts
index d73d956a19..e1cf863ec4 100644
--- a/packages/suggestion/src/index.ts
+++ b/packages/suggestion/src/index.ts
@@ -7,7 +7,9 @@ export * from './createSuggestionPlugin';
export * from './types';
export * from './useHooksSuggestion';
export * from './withSuggestion';
+export * from './diff-to-suggestions/index';
export * from './queries/index';
+export * from './slate-diff/index';
export * from './store/index';
export * from './transforms/index';
export * from './utils/index';
diff --git a/packages/suggestion/src/slate-diff/LICENSE b/packages/suggestion/src/slate-diff/LICENSE
new file mode 100644
index 0000000000..c832c847da
--- /dev/null
+++ b/packages/suggestion/src/slate-diff/LICENSE
@@ -0,0 +1,203 @@
+https://github.com/pubuzhixing8/slate-diff/blob/main/LICENSE
+
+ 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/packages/suggestion/src/slate-diff/basic-diff.test.ts b/packages/suggestion/src/slate-diff/basic-diff.test.ts
new file mode 100644
index 0000000000..40f050d6c7
--- /dev/null
+++ b/packages/suggestion/src/slate-diff/basic-diff.test.ts
@@ -0,0 +1,30 @@
+import { dmp } from './internal/utils/dmp';
+
+describe('dmp', () => {
+ it('modify BC -> FM', () => {
+ const old_text = 'ABCD';
+ const new_text = 'AFMD';
+ const diff = dmp.diff_main(old_text, new_text);
+ expect(JSON.stringify(diff)).toStrictEqual(
+ JSON.stringify([
+ [0, 'A'],
+ [-1, 'BC'],
+ [1, 'FM'],
+ [0, 'D'],
+ ])
+ );
+ });
+ it('insert E and modify BC -> FM', () => {
+ const old_text = 'ABCD';
+ const new_text = 'AEFMD';
+ const diff = dmp.diff_main(old_text, new_text);
+ expect(JSON.stringify(diff)).toStrictEqual(
+ JSON.stringify([
+ [0, 'A'],
+ [-1, 'BC'],
+ [1, 'EFM'],
+ [0, 'D'],
+ ])
+ );
+ });
+});
diff --git a/packages/suggestion/src/slate-diff/index.ts b/packages/suggestion/src/slate-diff/index.ts
new file mode 100644
index 0000000000..8a9c3a8ac0
--- /dev/null
+++ b/packages/suggestion/src/slate-diff/index.ts
@@ -0,0 +1,7 @@
+/**
+ * @file Automatically generated by barrelsby.
+ */
+
+export * from './basic-diff.test';
+export * from './slate-diff.test';
+export * from './slate-diff';
diff --git a/packages/suggestion/src/slate-diff/internal/transforms/transformDiffNodes.ts b/packages/suggestion/src/slate-diff/internal/transforms/transformDiffNodes.ts
new file mode 100644
index 0000000000..378a202a51
--- /dev/null
+++ b/packages/suggestion/src/slate-diff/internal/transforms/transformDiffNodes.ts
@@ -0,0 +1,117 @@
+/* eslint-disable no-restricted-syntax */
+import { TDescendant, TOperation } from '@udecode/plate-common';
+import { isEqual } from 'lodash';
+
+import { slateDiff } from '../../slate-diff';
+import { copyWithout } from '../utils/copy-without';
+
+// We try each of the Handler functions listed below until one of them
+// matches. When one does, that is used to compute the operations. At
+// least one will, since the last one is a fallback that works for any
+// input.
+
+// Define a type for handler functions that take two nodes and a path, and return an array of Slate operations or undefined
+type Handler = (
+ node: TDescendant,
+ nextNode: TDescendant,
+ path: number[]
+) => TOperation[] | undefined;
+
+// Initialize an array to hold different transformation strategies
+const STRATEGIES: Handler[] = [];
+
+/*
+Common special case -- only the children change:
+
+If we have two blocks and only the children change,
+we recursively call our top level diff algorithm on
+those children. */
+// Strategy for when only the children of nodes change
+STRATEGIES.push(
+ (node, nextNode, path) => {
+ // Check if both nodes have children and their properties except 'children' are equal
+ if (
+ node['children'] != null &&
+ nextNode['children'] != null &&
+ isEqual(
+ copyWithout(node, ['children']),
+ copyWithout(nextNode, ['children'])
+ )
+ ) {
+ // If only children have changed, recursively apply the diff algorithm to the children
+ return slateDiff(
+ node['children'] as TDescendant[],
+ nextNode['children'] as TDescendant[],
+ path
+ );
+ }
+ return [];
+ },
+ (node, nextNode, path) => {
+ const properties: any = {};
+ const newProperties: any = {};
+
+ // Loop through all properties of the original node
+ for (const key in node) {
+ if (!isEqual(node[key], nextNode[key])) {
+ if (key === 'children' || key === 'text') return; // can't do via set_node
+ properties[key] = node[key];
+ newProperties[key] = nextNode[key];
+ }
+ }
+ // Loop through properties of the next node to find new properties not present in the original node
+ for (const key in nextNode) {
+ if (node[key] === undefined) {
+ if (key === 'children' || key === 'text') return; // can't do via set_node
+ newProperties[key] = nextNode[key];
+ }
+ }
+
+ // Return an operation to set the node with new properties if there are changes other than 'children' and 'text'
+ return [
+ {
+ type: 'set_node',
+ path,
+ properties,
+ newProperties,
+ },
+ ];
+ },
+ (node, nextNode, path) => {
+ // const { text, children, ...properties } = node;
+
+ // If no specific strategy applies, remove the original node and insert the new node
+ return [
+ {
+ type: 'remove_node',
+ path,
+ node,
+ },
+ {
+ type: 'insert_node',
+ path,
+ node: nextNode,
+ },
+ ];
+ }
+);
+
+// Replace node at path by nextNode using the first strategy that works.
+export function transformDiffNodes(
+ node: TDescendant,
+ nextNode: TDescendant,
+ path: number[]
+): TOperation[] {
+ // Try each strategy in turn
+ for (const strategy of STRATEGIES) {
+ // Attempt to generate operations with the current strategy
+ const ops = strategy(node, nextNode, path);
+
+ if (ops && ops.length > 0) {
+ // Return the operations if the strategy succeeds
+ return ops;
+ }
+ }
+ // If no strategy succeeds, throw an error (should never happen because of the fallback strategy)
+ throw new Error('BUG');
+}
diff --git a/packages/suggestion/src/slate-diff/internal/transforms/transformDiffTexts.ts b/packages/suggestion/src/slate-diff/internal/transforms/transformDiffTexts.ts
new file mode 100644
index 0000000000..35c49698da
--- /dev/null
+++ b/packages/suggestion/src/slate-diff/internal/transforms/transformDiffTexts.ts
@@ -0,0 +1,192 @@
+import { TOperation, TText } from '@udecode/plate-common';
+import { Path } from 'slate';
+
+import { dmp } from '../utils/dmp';
+import { getProperties } from '../utils/get-properties';
+
+// Main function to transform an array of text nodes into another array of text nodes
+export function transformDiffTexts(
+ nodes: TText[],
+ nextNodes: TText[],
+ path: number[]
+): TOperation[] {
+ // Validate input - both arrays must have at least one node
+ if (nodes.length === 0) throw new Error('must have at least one nodes');
+ if (nextNodes.length === 0)
+ throw new Error('must have at least one nextNodes');
+
+ const operations: TOperation[] = [];
+
+ // Start with the first node in the array, assuming all nodes are to be merged into one
+ let node = nodes[0];
+
+ if (nodes.length > 1) {
+ // If there are multiple nodes, merge them into one, adding merge operations
+ for (let i = 1; i < nodes.length; i++) {
+ operations.push({
+ type: 'merge_node',
+ path: Path.next(path),
+ position: 0, // Required by type; not actually used here
+ properties: {}, // Required by type; not actually used here
+ });
+ // Update the node's text with the merged text (for splitTextNodes)
+ node = { ...node, text: node.text + nodes[i].text };
+ }
+ }
+
+ // After merging, apply split operations based on the target state (`nextNodes`)
+ for (const op of splitTextNodes(node, nextNodes, path)) {
+ operations.push(op);
+ }
+
+ return operations;
+}
+
+// Function to compute the text operations needed to transform string `a` into string `b`
+function slateTextDiff(a: string, b: string): Op[] {
+ // Compute the diff between two strings
+ const diff = dmp.diff_main(a, b);
+
+ const operations: Op[] = [];
+
+ // Initialize an offset to track position within the string
+ let offset = 0;
+ // Initialize an index to iterate through the diff chunks
+ let i = 0;
+
+ while (i < diff.length) {
+ const chunk = diff[i];
+ const op = chunk[0]; // Operation code: -1 = delete, 0 = leave unchanged, 1 = insert
+ const text = chunk[1]; // The text associated with this diff chunk
+
+ switch (op) {
+ case 0: {
+ // For unchanged text, just move the offset forward
+ offset += text.length;
+
+ break;
+ }
+ case -1: {
+ // For deletions, add a remove_text operation
+ operations.push({ type: 'remove_text', offset, text });
+
+ break;
+ }
+ case 1: {
+ // For insertions, add an insert_text operation
+ operations.push({ type: 'insert_text', offset, text });
+ // Move the offset forward by the length of the inserted text
+ offset += text.length;
+
+ break;
+ }
+ // No default
+ }
+ // Move to the next diff chunk
+ i += 1;
+ }
+ // console.info("slateTextDiff", { a, b, diff, operations });
+
+ return operations;
+}
+
+/* Accomplish something like this
+
+node={"text":"xyz A **B** C"} ->
+ split={"text":"A "} {"text":"B","bold":true} {"text":" C"}
+
+via a combination of remove_text/insert_text as above and split_node
+operations.
+*/
+// Function to split a single text node into multiple nodes based on the desired target state
+function splitTextNodes(
+ node: TText,
+ split: TText[],
+ path: number[] // the path to node.
+): TOperation[] {
+ if (split.length === 0) {
+ // If there are no target nodes, simply remove the original node
+ return [
+ {
+ type: 'remove_node',
+ node,
+ path,
+ },
+ ];
+ }
+
+ // Start with the concatenated text of the target state
+ let splitText = '';
+ for (const { text } of split) {
+ splitText += text;
+ }
+ const nodeText = node.text;
+ const operations: TOperation[] = [];
+
+ // If the concatenated target text differs from the original, compute the necessary text transformations
+ if (splitText !== nodeText) {
+ // Use diff-match-pach to transform the text in the source node to equal
+ // the text in the sequence of target nodes. Once we do this transform,
+ // we can then worry about splitting up the resulting source node.
+ for (const op of slateTextDiff(nodeText, splitText)) {
+ // TODO: maybe path has to be changed if there are multiple OPS?
+ operations.push({ path, ...op });
+ }
+ }
+
+ // Adjust properties of the initial node to match the first target node, if necessary
+ const newProperties = getProperties(split[0], node);
+ if (getKeysLength(newProperties) > 0) {
+ operations.push({
+ type: 'set_node',
+ path,
+ properties: getProperties(node),
+ newProperties,
+ });
+ }
+
+ let properties = getProperties(split[0]);
+ // For each segment in the target state, split the node and adjust properties as needed
+ let splitPath = path;
+ for (let i = 0; i < split.length - 1; i++) {
+ const part = split[i];
+ const nextPart = split[i + 1];
+ const newProps = getProperties(nextPart, properties);
+
+ operations.push({
+ type: 'split_node',
+ path: splitPath,
+ position: part.text.length,
+ properties: newProps,
+ });
+
+ splitPath = Path.next(splitPath);
+ properties = getProperties(nextPart);
+ }
+ return operations;
+}
+
+/*
+NOTE: the set_node api lets you delete properties by setting
+them to null, but the split_node api doesn't (I guess Ian forgot to
+implement that... or there is a good reason). So if there are any
+property deletes, then we have to also do a set_node... or just be
+ok with undefined values. For text where values are treated as
+booleans, this is fine and that's what we do. Maybe the reason
+is just to keep the operations simple and minimal.
+Also setting to undefined / false-ish for a *text* node property
+is equivalent to not having it regarding everything else.
+*/
+
+function getKeysLength(obj: object | undefined | null): number {
+ if (obj == null) {
+ return 0;
+ }
+ return Object.keys(obj).length;
+}
+
+interface Op {
+ type: 'insert_text' | 'remove_text';
+ offset: number;
+ text: string;
+}
diff --git a/packages/suggestion/src/slate-diff/internal/utils/children-to-strings.ts b/packages/suggestion/src/slate-diff/internal/utils/children-to-strings.ts
new file mode 100644
index 0000000000..8625d74da1
--- /dev/null
+++ b/packages/suggestion/src/slate-diff/internal/utils/children-to-strings.ts
@@ -0,0 +1,16 @@
+import { TDescendant } from '@udecode/plate-common';
+
+const stringify = JSON.stringify;
+
+// We could instead use
+// import * as stringify from "json-stable-stringify";
+// which might sometimes avoid a safe "false positive" (i.e., slightly
+
+// less efficient patch), but is significantly slower.
+export function childrenToStrings(children: TDescendant[]): string[] {
+ const v: string[] = [];
+ for (const node of children) {
+ v.push(stringify(node));
+ }
+ return v;
+}
diff --git a/packages/suggestion/src/slate-diff/internal/utils/copy-without.ts b/packages/suggestion/src/slate-diff/internal/utils/copy-without.ts
new file mode 100644
index 0000000000..cff7ebe093
--- /dev/null
+++ b/packages/suggestion/src/slate-diff/internal/utils/copy-without.ts
@@ -0,0 +1,16 @@
+/* eslint-disable no-restricted-syntax */
+// copy of map but without some keys
+// I.e., restrict a function to the complement of a subset of the domain.
+export function copyWithout(obj: any, w: string | string[]): any {
+ if (typeof w === 'string') {
+ w = [w];
+ }
+ const r: any = {};
+ for (const key in obj) {
+ const y = obj[key];
+ if (!Array.from(w).includes(key)) {
+ r[key] = y;
+ }
+ }
+ return r;
+}
diff --git a/packages/suggestion/src/slate-diff/internal/utils/diff-nodes.ts b/packages/suggestion/src/slate-diff/internal/utils/diff-nodes.ts
new file mode 100644
index 0000000000..5236096869
--- /dev/null
+++ b/packages/suggestion/src/slate-diff/internal/utils/diff-nodes.ts
@@ -0,0 +1,85 @@
+import { isElement, isText, TDescendant } from '@udecode/plate-common';
+import { isEqual } from 'lodash';
+
+import { copyWithout } from './copy-without';
+
+export function diffNodes(
+ originNodes: TDescendant[],
+ targetNodes: TDescendant[]
+) {
+ const result: NodeRelatedItem[] = [];
+ let relatedNode: TDescendant | undefined;
+ const leftTargetNodes: TDescendant[] = [...targetNodes];
+
+ originNodes.forEach((originNode: TDescendant) => {
+ let childrenUpdated = false;
+ let nodeUpdated = false;
+ relatedNode = leftTargetNodes.find((targetNode: TDescendant) => {
+ if (isEqualNode(originNode, targetNode)) {
+ childrenUpdated = true;
+ }
+ if (isEqualNodeChildren(originNode, targetNode)) {
+ nodeUpdated = true;
+ }
+ return nodeUpdated || childrenUpdated;
+ });
+ if (relatedNode) {
+ const insertNodes = leftTargetNodes.splice(
+ 0,
+ leftTargetNodes.indexOf(relatedNode)
+ );
+ insertNodes.forEach((insertNode) => {
+ result.push({
+ originNode: insertNode,
+ insert: true,
+ });
+ });
+ leftTargetNodes.splice(0, 1);
+ }
+ result.push({
+ originNode,
+ relatedNode,
+ childrenUpdated,
+ nodeUpdated,
+ delete: !relatedNode,
+ });
+ });
+ leftTargetNodes.forEach((insertNode) => {
+ result.push({
+ originNode: insertNode,
+ insert: true,
+ });
+ });
+ return result;
+}
+
+export type NodeRelatedItem = {
+ originNode: TDescendant;
+ relatedNode?: TDescendant;
+ childrenUpdated?: boolean;
+ nodeUpdated?: boolean;
+ insert?: boolean;
+ delete?: boolean;
+};
+
+export function isEqualNode(value: TDescendant, other: TDescendant) {
+ return (
+ isElement(value) &&
+ isElement(other) &&
+ value.children !== null &&
+ other.children !== null &&
+ isEqual(copyWithout(value, ['children']), copyWithout(other, ['children']))
+ );
+}
+
+export function isEqualNodeChildren(value: TDescendant, other: TDescendant) {
+ if (
+ isElement(value) &&
+ isElement(other) &&
+ isEqual(value.children, other.children)
+ ) {
+ return true;
+ }
+
+ return isText(value) && isText(other) && isEqual(value.text, other.text);
+}
diff --git a/packages/suggestion/src/slate-diff/internal/utils/dmp.ts b/packages/suggestion/src/slate-diff/internal/utils/dmp.ts
new file mode 100644
index 0000000000..484cf39c5a
--- /dev/null
+++ b/packages/suggestion/src/slate-diff/internal/utils/dmp.ts
@@ -0,0 +1,4 @@
+import { DiffMatchPatch } from 'diff-match-patch-ts';
+
+export const dmp = new DiffMatchPatch();
+dmp.Diff_Timeout = 0.2; // computing a diff won't block longer than about 0.2s
diff --git a/packages/suggestion/src/slate-diff/internal/utils/generate-operations.ts b/packages/suggestion/src/slate-diff/internal/utils/generate-operations.ts
new file mode 100644
index 0000000000..87bd78013a
--- /dev/null
+++ b/packages/suggestion/src/slate-diff/internal/utils/generate-operations.ts
@@ -0,0 +1,130 @@
+import { isTextList, TOperation, TPath } from '@udecode/plate-common';
+
+import { transformDiffNodes } from '../transforms/transformDiffNodes';
+import { transformDiffTexts } from '../transforms/transformDiffTexts';
+import { diffNodes, NodeRelatedItem } from './diff-nodes';
+import { StringCharMapping } from './string-char-mapping';
+import { stringToNodes } from './string-to-nodes';
+
+export function generateOperations(
+ diff: {
+ // op: -1 = delete, 0 = leave unchanged, 1 = insert
+ 0: number;
+ // value of the diff chunk
+ 1: string;
+ }[],
+ path: TPath,
+ stringCharMapping: StringCharMapping
+) {
+ // Current index in the document
+ let index = 0;
+ // Current index in the diff array
+ let i = 0;
+ const operations: TOperation[] = [];
+
+ while (i < diff.length) {
+ const chunk = diff[i];
+ const op = chunk[0]; //
+ const val = chunk[1];
+
+ // If operation code is 0, it means the chunk is unchanged
+ if (op === 0) {
+ // Skip over unchanged text by advancing the index
+ index += val.length;
+ // Move to the next diff chunk
+ i += 1;
+ continue;
+ }
+
+ // Convert the string value to document nodes based on the stringCharMapping
+ const nodes = stringToNodes(val, stringCharMapping);
+
+ // Handle deletion (-1)
+ if (op === -1) {
+ // Check if the next chunk is an insertion (1), indicating a replace operation
+ if (i < diff.length - 1 && diff[i + 1][0] === 1) {
+ // Value of the next chunk (to be inserted)
+ const nextVal = diff[i + 1][1];
+ // Convert next value to nodes
+ const nextNodes = stringToNodes(nextVal, stringCharMapping);
+
+ // If both current and next chunks are text nodes, use transformTextNodes
+ if (isTextList(nodes) && isTextList(nextNodes)) {
+ for (const textOp of transformDiffTexts(
+ nodes,
+ nextNodes,
+ path.concat([index])
+ )) {
+ // Add operations from transforming text nodes
+ operations.push(textOp);
+ }
+ // Advance the index by the length of the next nodes
+ index += nextNodes.length;
+ // Consume two diff chunks (delete and insert)
+ i += 2;
+ continue;
+ }
+
+ // If not all nodes are text nodes, use diffNodes to generate operations
+ const diffResult = diffNodes(nodes, nextNodes);
+ diffResult.forEach((item: NodeRelatedItem) => {
+ if (item.delete) {
+ operations.push({
+ type: 'remove_node',
+ path: path.concat([index]),
+ node: item.originNode,
+ } as TOperation);
+ }
+ if (item.insert) {
+ operations.push({
+ type: 'insert_node',
+ path: path.concat([index]),
+ node: item.originNode,
+ } as TOperation);
+ // Adjust index for each inserted node
+ index += 1;
+ }
+ if (item.relatedNode) {
+ operations.push(
+ ...transformDiffNodes(
+ item.originNode,
+ item.relatedNode,
+ path.concat([index])
+ )
+ );
+ index += 1;
+ }
+ });
+ i += 2; // this consumed two entries from the diff array.
+ continue;
+ } else {
+ // Plain delete of some nodes (with no insert immediately after)
+ for (const node of nodes) {
+ operations.push({
+ type: 'remove_node',
+ path: path.concat([index]),
+ node,
+ } as TOperation);
+ }
+ i += 1; // consumes only one entry from diff array.
+ continue;
+ }
+ }
+ if (op === 1) {
+ // insert new nodes.
+ for (const node of nodes) {
+ operations.push({
+ type: 'insert_node',
+ path: path.concat([index]),
+ node,
+ });
+ index += 1;
+ }
+ i += 1;
+ continue;
+ }
+ throw new Error('BUG');
+ }
+
+ return operations;
+}
diff --git a/packages/suggestion/src/slate-diff/internal/utils/get-properties.ts b/packages/suggestion/src/slate-diff/internal/utils/get-properties.ts
new file mode 100644
index 0000000000..a43511cc71
--- /dev/null
+++ b/packages/suggestion/src/slate-diff/internal/utils/get-properties.ts
@@ -0,0 +1,39 @@
+/* eslint-disable no-restricted-syntax */
+import { TText } from '@udecode/plate-common';
+
+// Get object that will set the properties of before
+// to equal the properties of node, in terms of the
+// slatejs set_node operation. If before is not given,
+// just gives all the non-text propers of goal.
+export function getProperties(goal: TText, before?: TText): any {
+ const props: any = {};
+ for (const x in goal) {
+ if (x !== 'text') {
+ if (before == null) {
+ if (goal[x]) {
+ props[x] = goal[x];
+ }
+ // continue
+ } else {
+ if (goal[x] !== before[x]) {
+ // eslint-disable-next-line unicorn/prefer-ternary
+ if (goal[x]) {
+ props[x] = goal[x];
+ } else {
+ props[x] = undefined; // remove property...
+ }
+ }
+ }
+ }
+ }
+ if (before != null) {
+ // also be sure to explicitly remove props not in goal
+ // WARNING: this might change in slatejs; I saw a discussion about this.
+ for (const x in before) {
+ if (x !== 'text' && goal[x] == null) {
+ props[x] = undefined;
+ }
+ }
+ }
+ return props;
+}
diff --git a/packages/suggestion/src/slate-diff/internal/utils/letter-to-node.ts b/packages/suggestion/src/slate-diff/internal/utils/letter-to-node.ts
new file mode 100644
index 0000000000..6f72d7ab78
--- /dev/null
+++ b/packages/suggestion/src/slate-diff/internal/utils/letter-to-node.ts
@@ -0,0 +1,11 @@
+import { TDescendant } from '@udecode/plate-common';
+
+import { StringCharMapping } from './string-char-mapping';
+
+export function letterToNode(x: string, stringMapping: StringCharMapping) {
+ const node: TDescendant = JSON.parse(stringMapping._to_string[x]);
+ if (node == null) {
+ throw new Error('letterToNode: bug');
+ }
+ return node;
+}
diff --git a/packages/suggestion/src/slate-diff/internal/utils/string-char-mapping.ts b/packages/suggestion/src/slate-diff/internal/utils/string-char-mapping.ts
new file mode 100644
index 0000000000..30f24b02f2
--- /dev/null
+++ b/packages/suggestion/src/slate-diff/internal/utils/string-char-mapping.ts
@@ -0,0 +1,37 @@
+export class StringCharMapping {
+ private _to_char: { [s: string]: string } = {};
+ private _next_char: string = 'A';
+ public _to_string: { [s: string]: string } = {}; // yes, this is publicly accessed (TODO: fix)
+
+ private find_next_char(): void {
+ while (true) {
+ this._next_char = String.fromCodePoint(
+ this._next_char.codePointAt(0)! + 1
+ );
+ if (this._to_string[this._next_char] == null) {
+ // found it!
+ break;
+ }
+ }
+ }
+
+ public to_string(strings: string[]): string {
+ let t = '';
+ for (const s of strings) {
+ const a = this._to_char[s];
+ if (a == null) {
+ t += this._next_char;
+ this._to_char[s] = this._next_char;
+ this._to_string[this._next_char] = s;
+ this.find_next_char();
+ } else {
+ t += a;
+ }
+ }
+ return t;
+ }
+
+ public to_array(x: string): string[] {
+ return Array.from(x).map((s) => this.to_string([s]));
+ }
+}
diff --git a/packages/suggestion/src/slate-diff/internal/utils/string-to-nodes.ts b/packages/suggestion/src/slate-diff/internal/utils/string-to-nodes.ts
new file mode 100644
index 0000000000..a0475d6e71
--- /dev/null
+++ b/packages/suggestion/src/slate-diff/internal/utils/string-to-nodes.ts
@@ -0,0 +1,15 @@
+import { TDescendant } from '@udecode/plate-common';
+
+import { letterToNode } from './letter-to-node';
+import { StringCharMapping } from './string-char-mapping';
+
+export function stringToNodes(
+ s: string,
+ stringMapping: StringCharMapping
+): TDescendant[] {
+ const nodes: TDescendant[] = [];
+ for (const x of s) {
+ nodes.push(letterToNode(x, stringMapping));
+ }
+ return nodes;
+}
diff --git a/packages/suggestion/src/slate-diff/slate-diff.fixtures.ts b/packages/suggestion/src/slate-diff/slate-diff.fixtures.ts
new file mode 100644
index 0000000000..3bcbd8333e
--- /dev/null
+++ b/packages/suggestion/src/slate-diff/slate-diff.fixtures.ts
@@ -0,0 +1,314 @@
+export const addMarkFixtures = {
+ doc1: [
+ {
+ type: 'paragraph',
+ children: [{ text: 'PingCode Wiki & Worktile' }],
+ },
+ ],
+ doc2: [
+ {
+ type: 'paragraph',
+ children: [
+ { text: 'PingCode ' },
+ { text: 'Wiki', bold: true },
+ { text: ' & Worktile' },
+ ],
+ },
+ ],
+ expected: [
+ {
+ path: [0, 0],
+ position: 9,
+ properties: { bold: true },
+ type: 'split_node',
+ },
+ {
+ path: [0, 1],
+ position: 4,
+ properties: {
+ bold: undefined,
+ },
+ type: 'split_node',
+ },
+ ],
+};
+
+export const insertUpdateParagraphFixtures = {
+ doc1: [
+ {
+ type: 'paragraph',
+ children: [{ text: 'This is the first paragraph.' }],
+ key: '1',
+ },
+ {
+ type: 'paragraph',
+ children: [{ text: 'This is the third paragraph.' }],
+ key: '3',
+ },
+ {
+ type: 'paragraph',
+ children: [{ text: 'This is the fourth paragraph.' }],
+ key: '4',
+ },
+ ],
+ doc2: [
+ {
+ type: 'paragraph',
+ children: [{ text: 'This is the first paragraph.' }],
+ key: '1',
+ },
+ {
+ type: 'paragraph',
+ children: [{ text: 'This is the second paragraph.' }],
+ key: '2',
+ },
+ {
+ type: 'paragraph',
+ children: [
+ { text: 'This is the third paragraph, and insert some text.' },
+ ],
+ key: '3',
+ },
+ {
+ type: 'paragraph',
+ children: [{ text: 'This is the fourth paragraph.' }],
+ key: '4',
+ },
+ ],
+ expected: [
+ {
+ node: {
+ children: [{ text: 'This is the second paragraph.' }],
+ key: '2',
+ type: 'paragraph',
+ },
+ path: [1],
+ type: 'insert_node',
+ },
+ {
+ offset: 27,
+ path: [2, 0],
+ text: ', and insert some text',
+ type: 'insert_text',
+ },
+ ],
+};
+export const insertUpdateTwoParagraphsFixtures = {
+ doc1: [
+ {
+ type: 'paragraph',
+ children: [{ text: 'This is the first paragraph.' }],
+ key: '1',
+ },
+ {
+ type: 'paragraph',
+ children: [{ text: 'This is the third paragraph.' }],
+ key: '3',
+ },
+ {
+ type: 'paragraph',
+ children: [{ text: 'This is the fourth paragraph.' }],
+ key: '4',
+ },
+ ],
+ doc2: [
+ {
+ type: 'paragraph',
+ children: [{ text: 'This is the first paragraph.' }],
+ key: '1',
+ },
+ {
+ type: 'paragraph',
+ children: [{ text: 'This is the second paragraph.' }],
+ key: '2',
+ },
+ {
+ type: 'paragraph',
+ children: [
+ { text: 'This is the third paragraph, and insert some text.' },
+ ],
+ key: '3',
+ },
+ {
+ type: 'paragraph',
+ children: [{ text: 'This is the fifth paragraph.' }],
+ key: '5',
+ },
+ {
+ type: 'paragraph',
+ children: [
+ { text: 'This is the fourth paragraph, and insert some text.' },
+ ],
+ key: '4',
+ },
+ ],
+ expected: [
+ {
+ node: {
+ children: [{ text: 'This is the second paragraph.' }],
+ key: '2',
+ type: 'paragraph',
+ },
+ path: [1],
+ type: 'insert_node',
+ },
+ {
+ offset: 27,
+ path: [2, 0],
+ text: ', and insert some text',
+ type: 'insert_text',
+ },
+ {
+ node: {
+ children: [{ text: 'This is the fifth paragraph.' }],
+ key: '5',
+ type: 'paragraph',
+ },
+ path: [3],
+ type: 'insert_node',
+ },
+ {
+ offset: 28,
+ path: [4, 0],
+ text: ', and insert some text',
+ type: 'insert_text',
+ },
+ ],
+};
+export const insertTextAddMarkFixtures = {
+ doc1: [
+ {
+ type: 'paragraph',
+ children: [{ text: 'PingCode' }],
+ },
+ ],
+ doc2: [
+ {
+ type: 'paragraph',
+ children: [
+ {
+ text: 'PingCode',
+ bold: true,
+ },
+ {
+ text: ' & ',
+ },
+ {
+ text: 'Worktile',
+ bold: true,
+ },
+ ],
+ },
+ ],
+ expected: [
+ {
+ type: 'insert_text',
+ path: [0, 0],
+ offset: 8,
+ text: ' & Worktile',
+ },
+ {
+ type: 'set_node',
+ path: [0, 0],
+ properties: {},
+ newProperties: {
+ bold: true,
+ },
+ },
+ {
+ type: 'split_node',
+ path: [0, 0],
+ position: 8,
+ properties: {
+ bold: undefined,
+ },
+ },
+ {
+ type: 'split_node',
+ path: [0, 1],
+ position: 3,
+ properties: {
+ bold: true,
+ },
+ },
+ ],
+};
+
+export const insertTextFixtures = {
+ doc1: [
+ {
+ type: 'paragraph',
+ children: [{ text: 'PingCode' }],
+ },
+ ],
+ doc2: [
+ {
+ type: 'paragraph',
+ children: [{ text: 'PingCode & Worktile' }],
+ },
+ ],
+ expected: [
+ {
+ type: 'insert_text',
+ path: [0, 0],
+ offset: 8,
+ text: ' & Worktile',
+ },
+ ],
+};
+export const mergeTextFixtures = {
+ doc1: [
+ {
+ type: 'paragraph',
+ children: [
+ {
+ text: 'PingCode',
+ bold: true,
+ },
+ {
+ text: ' & ',
+ },
+ {
+ text: 'Worktile',
+ bold: true,
+ },
+ ],
+ },
+ ],
+ doc2: [
+ {
+ type: 'paragraph',
+ children: [{ text: 'PingCode' }],
+ },
+ ],
+ expected: [
+ {
+ type: 'merge_node',
+ path: [0, 1],
+ position: 0,
+ properties: {},
+ },
+ {
+ type: 'merge_node',
+ path: [0, 1],
+ position: 0,
+ properties: {},
+ },
+ {
+ type: 'remove_text',
+ path: [0, 0],
+ offset: 8,
+ text: ' & Worktile',
+ },
+ {
+ type: 'set_node',
+ path: [0, 0],
+ properties: {
+ bold: true,
+ },
+ newProperties: {
+ bold: undefined,
+ },
+ },
+ ],
+};
diff --git a/packages/suggestion/src/slate-diff/slate-diff.test.ts b/packages/suggestion/src/slate-diff/slate-diff.test.ts
new file mode 100644
index 0000000000..65f070cfe1
--- /dev/null
+++ b/packages/suggestion/src/slate-diff/slate-diff.test.ts
@@ -0,0 +1,51 @@
+import { slateDiff } from './slate-diff';
+import {
+ addMarkFixtures,
+ insertTextAddMarkFixtures,
+ insertTextFixtures,
+ insertUpdateParagraphFixtures,
+ insertUpdateTwoParagraphsFixtures,
+ mergeTextFixtures,
+} from './slate-diff.fixtures';
+
+describe('slate-diff', () => {
+ it('insert-text', () => {
+ expect(
+ slateDiff(insertTextFixtures.doc1, insertTextFixtures.doc2)
+ ).toStrictEqual(insertTextFixtures.expected);
+ });
+
+ it('add-mark', () => {
+ expect(slateDiff(addMarkFixtures.doc1, addMarkFixtures.doc2)).toStrictEqual(
+ addMarkFixtures.expected
+ );
+ });
+
+ it('insert-text-and-add-mark', () => {
+ expect(
+ slateDiff(insertTextAddMarkFixtures.doc1, insertTextAddMarkFixtures.doc2)
+ ).toStrictEqual(insertTextAddMarkFixtures.expected);
+ });
+
+ it('merge-text', () => {
+ expect(
+ slateDiff(mergeTextFixtures.doc1, mergeTextFixtures.doc2)
+ ).toStrictEqual(mergeTextFixtures.expected);
+ });
+
+ it('insert-and-update-paragraph', () => {
+ const diff = slateDiff(
+ insertUpdateParagraphFixtures.doc1,
+ insertUpdateParagraphFixtures.doc2
+ );
+ expect(diff).toStrictEqual(insertUpdateParagraphFixtures.expected);
+ });
+
+ it('insert-and-update-two-paragraphs', () => {
+ const diff = slateDiff(
+ insertUpdateTwoParagraphsFixtures.doc1,
+ insertUpdateTwoParagraphsFixtures.doc2
+ );
+ expect(diff).toStrictEqual(insertUpdateTwoParagraphsFixtures.expected);
+ });
+});
diff --git a/packages/suggestion/src/slate-diff/slate-diff.ts b/packages/suggestion/src/slate-diff/slate-diff.ts
new file mode 100644
index 0000000000..de0e12eb97
--- /dev/null
+++ b/packages/suggestion/src/slate-diff/slate-diff.ts
@@ -0,0 +1,24 @@
+import { TDescendant, TOperation } from '@udecode/plate-common';
+
+import { childrenToStrings } from './internal/utils/children-to-strings';
+import { dmp } from './internal/utils/dmp';
+import { generateOperations } from './internal/utils/generate-operations';
+import { StringCharMapping } from './internal/utils/string-char-mapping';
+
+export function slateDiff(
+ doc0: TDescendant[],
+ doc1: TDescendant[],
+ path: number[] = []
+): TOperation[] {
+ const string_mapping = new StringCharMapping();
+
+ const s0 = childrenToStrings(doc0);
+ const s1 = childrenToStrings(doc1);
+
+ const m0 = string_mapping.to_string(s0);
+ const m1 = string_mapping.to_string(s1);
+
+ const diff = dmp.diff_main(m0, m1);
+
+ return generateOperations(diff, path, string_mapping);
+}
diff --git a/yarn.lock b/yarn.lock
index 0ac3888882..95605d9e5e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6889,6 +6889,8 @@ __metadata:
resolution: "@udecode/plate-suggestion@workspace:packages/suggestion"
dependencies:
"@udecode/plate-common": "npm:30.1.2"
+ diff-match-patch-ts: "npm:0.3.0"
+ lodash: "npm:^4.17.21"
peerDependencies:
react: ">=16.8.0"
react-dom: ">=16.8.0"
@@ -9314,6 +9316,13 @@ __metadata:
languageName: node
linkType: hard
+"diff-match-patch-ts@npm:0.3.0":
+ version: 0.3.0
+ resolution: "diff-match-patch-ts@npm:0.3.0"
+ checksum: 046607a76a6b01ac45a50cc1732648f59790fbc2e32014ecc42627369c538fa1624f875f473f41c6732f8fd867404ae44438215b11b465aace76d0e5fb7fa1a2
+ languageName: node
+ linkType: hard
+
"diff-sequences@npm:^29.4.3":
version: 29.4.3
resolution: "diff-sequences@npm:29.4.3"