diff --git a/.changeset/dry-poets-give.md b/.changeset/dry-poets-give.md new file mode 100644 index 0000000000..f94c53aa6c --- /dev/null +++ b/.changeset/dry-poets-give.md @@ -0,0 +1,5 @@ +--- +"@udecode/plate-suggestion": minor +--- + +slate-diff diff --git a/apps/www/content/docs/examples/version-history.mdx b/apps/www/content/docs/examples/version-history.mdx new file mode 100644 index 0000000000..9ef6ee80f2 --- /dev/null +++ b/apps/www/content/docs/examples/version-history.mdx @@ -0,0 +1,6 @@ +--- +title: Version History +description: Show a diff of two different points in a Plate document's history. +--- + + diff --git a/apps/www/src/__registry__/index.tsx b/apps/www/src/__registry__/index.tsx index 28074e0074..bd3e1fd6d2 100644 --- a/apps/www/src/__registry__/index.tsx +++ b/apps/www/src/__registry__/index.tsx @@ -830,6 +830,13 @@ export const Index: Record = { files: ['registry/default/example/multiple-editors-demo.tsx'], component: React.lazy(() => import('@/registry/default/example/multiple-editors-demo')), }, + 'version-history-demo': { + name: 'version-history-demo', + type: 'components:example', + registryDependencies: [], + files: ['registry/default/example/version-history-demo.tsx'], + component: React.lazy(() => import('@/registry/default/example/version-history-demo')), + }, 'playground-demo': { name: 'playground-demo', type: 'components:example', diff --git a/apps/www/src/config/docs.ts b/apps/www/src/config/docs.ts index 9a54b85851..39ac11e5f7 100644 --- a/apps/www/src/config/docs.ts +++ b/apps/www/src/config/docs.ts @@ -258,6 +258,10 @@ export const docsConfig: DocsConfig = { title: 'Preview Markdown', href: '/docs/examples/preview-markdown', }, + { + title: 'Version History', + href: '/docs/examples/version-history', + }, ], }, { diff --git a/apps/www/src/registry/default/example/version-history-demo.tsx b/apps/www/src/registry/default/example/version-history-demo.tsx new file mode 100644 index 0000000000..41ec42434d --- /dev/null +++ b/apps/www/src/registry/default/example/version-history-demo.tsx @@ -0,0 +1,195 @@ +import React from 'react'; +import { Plate, PlateContent, PlateProps, Value, createPlugins, createPlateEditor, createPluginFactory, PlateLeafProps, PlateLeaf, PlateElementProps, PlateElement, isInline } from '@udecode/plate-common'; +import { ELEMENT_PARAGRAPH, createParagraphPlugin } from '@udecode/plate-paragraph'; +import {ParagraphElement} from '../plate-ui/paragraph-element'; +import {Button} from '../plate-ui/button'; +import {slateDiff, applyDiffToSuggestions} from '@udecode/plate-suggestion'; +import { createBoldPlugin, MARK_BOLD } from '@udecode/plate-basic-marks'; +import {cn, withProps} from '@udecode/cn'; +import {useSelected} from 'slate-react'; + +const ELEMENT_INLINE_VOID = 'inlineVoid'; + +const createInlineVoidPlugin = createPluginFactory({ + key: ELEMENT_INLINE_VOID, + isElement: true, + isInline: true, + isVoid: true, +}); + +const InlineVoidElement = ({ children, ...props }: PlateElementProps) => { + const selected = useSelected(); + return ( + + + Inline void + + {children} + + ); +}; + +const KEY_DIFF = 'diff'; +const MARK_SUGGESTION = 'suggestion'; + +const createDiffPlugin = createPluginFactory({ + key: KEY_DIFF, + plugins: [ + { + key: MARK_SUGGESTION, + isLeaf: true, + }, + ], + inject: { + aboveComponent: () => ({ element, children, editor }) => { + if (!element.suggestion) return children; + const Component = isInline(editor, element) ? 'span' : 'div'; + return ( + + {children} + + ); + }, + }, +}); + +function SuggestionLeaf({ children, ...props }: PlateLeafProps) { + const isDeletion = props.leaf.suggestionDeletion; + const isUpdate = !isDeletion && props.leaf.suggestionUpdate; + const Component = isDeletion ? 'del' : 'ins'; + + return ( + + + {children} + + + ); +} + +const plugins = createPlugins([ + createParagraphPlugin(), + createInlineVoidPlugin(), + createBoldPlugin(), + createDiffPlugin(), +], { + components: { + [ELEMENT_PARAGRAPH]: ParagraphElement, + [ELEMENT_INLINE_VOID]: InlineVoidElement, + [MARK_BOLD]: withProps(PlateLeaf, { as: 'strong' }), + [MARK_SUGGESTION]: SuggestionLeaf, + }, +}); + +const initialValue: Value = [ + { + type: ELEMENT_PARAGRAPH, + children: [{ text: 'This is a version history demo.' }], + }, + { + type: ELEMENT_PARAGRAPH, + children: [ + { text: 'Try editing the ' }, + { text: 'text and see what', bold: true }, + { text: ' happens.' } + ], + }, + { + type: ELEMENT_PARAGRAPH, + children: [ + { text: 'This is an ' }, + { type: ELEMENT_INLINE_VOID, children: [{ text: '' }] }, + { text: '. Try removing it.' }, + ], + }, +]; + +function VersionHistoryPlate(props: Omit) { + return ( + + + + ); +} + +interface DiffProps { + previous: Value; + current: Value; +} + +function Diff({ previous, current }: DiffProps) { + const operations = React.useMemo(() => slateDiff(previous, current), [previous, current]); + + const diffValue: Value = React.useMemo(() => { + const editor = createPlateEditor({ plugins }); + editor.children = previous; + applyDiffToSuggestions(editor, operations); + return editor.children; + }, [previous, current]); + + return ( + <> + + +
+        {JSON.stringify(operations, null, 2)}
+      
+ +
+        {JSON.stringify(diffValue, null, 2)}
+      
+ + ); +} + +export default function VersionHistoryDemo() { + const [revisions, setRevisions] = React.useState([initialValue]); + const [selectedRevisionIndex, setSelectedRevisionIndex] = React.useState(0); + const [value, setValue] = React.useState(initialValue); + + const selectedRevisionValue = React.useMemo(() => revisions[selectedRevisionIndex], [revisions, selectedRevisionIndex]); + + const saveRevision = () => { + setRevisions([...revisions, value]); + }; + + return ( +
+ + + + + + +
+
+

Revision {selectedRevisionIndex + 1}

+ +
+ +
+

Diff

+ +
+
+
+ ); +} 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"