From b756b4a03546877fada6aef1e6f97be48627d158 Mon Sep 17 00:00:00 2001 From: Andrey Sobolev Date: Tue, 8 Nov 2022 20:45:19 +0700 Subject: [PATCH] Collaboration editor diff (#2356) Signed-off-by: Andrey Sobolev --- common/config/rush/pnpm-lock.yaml | 144 ++++--- packages/panel/src/components/Panel.svelte | 11 +- packages/text-editor/lang/en.json | 3 +- packages/text-editor/lang/ru.json | 3 +- packages/text-editor/package.json | 13 +- packages/text-editor/src/changeSet.ts | 168 +++++++++ .../components/CollaborationDiffViewer.svelte | 278 ++++++++++++++ .../src/components/CollaboratorEditor.svelte | 353 +++++++++++++----- .../src/components/StyleButton.svelte | 9 +- .../src/components/diff/decorations.ts | 99 +++++ .../text-editor/src/components/diff/diff.ts | 163 ++++++++ .../src/components/diff/recreate.ts | 331 ++++++++++++++++ .../src/components/icons/Header.svelte | 8 + packages/text-editor/src/index.ts | 1 + packages/text-editor/src/plugin.ts | 3 +- packages/text-editor/src/types.ts | 3 +- packages/text-editor/src/uniqId.ts | 61 --- packages/ui/src/components/Panel.svelte | 5 +- plugins/document-assets/lang/en.json | 4 +- plugins/document-assets/lang/ru.json | 4 +- .../src/components/DocumentEditor.svelte | 28 +- .../src/components/EditDoc.svelte | 76 +++- plugins/document-resources/src/plugin.ts | 4 +- 23 files changed, 1546 insertions(+), 226 deletions(-) create mode 100644 packages/text-editor/src/changeSet.ts create mode 100644 packages/text-editor/src/components/CollaborationDiffViewer.svelte create mode 100644 packages/text-editor/src/components/diff/decorations.ts create mode 100644 packages/text-editor/src/components/diff/diff.ts create mode 100644 packages/text-editor/src/components/diff/recreate.ts create mode 100644 packages/text-editor/src/components/icons/Header.svelte delete mode 100644 packages/text-editor/src/uniqId.ts diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 56ca06fb41..f0fba9e4f0 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -197,8 +197,11 @@ specifiers: '@rushstack/heft': ^0.47.9 '@rushstack/heft-jest-plugin': ^0.3.16 '@tiptap/core': ~2.0.0-beta.199 + '@tiptap/extension-code-block': ~2.0.0-beta.200 '@tiptap/extension-collaboration': ~2.0.0-beta.199 '@tiptap/extension-collaboration-cursor': ~2.0.0-beta.199 + '@tiptap/extension-gapcursor': ~2.0.0-beta.200 + '@tiptap/extension-heading': ~2.0.0-beta.200 '@tiptap/extension-highlight': ~2.0.0-beta.199 '@tiptap/extension-link': ~2.0.0-beta.199 '@tiptap/extension-mention': ~2.0.0-beta.199 @@ -213,6 +216,7 @@ specifiers: '@types/cors': ^2.8.12 '@types/crypto-js': ^4.1.1 '@types/deep-equal': ^1.0.1 + '@types/diff': ~5.0.2 '@types/express': ^4.17.13 '@types/express-fileupload': ^1.1.7 '@types/faker': ~5.5.9 @@ -248,6 +252,7 @@ specifiers: css-loader: ^5.2.1 csv-parse: ~5.1.0 deep-equal: ^2.0.5 + diff: ~5.1.0 dotenv: ~16.0.0 dotenv-webpack: ^7.0.2 elastic-apm-node: ~3.26.0 @@ -288,9 +293,13 @@ specifiers: postcss-loader: ^6.1.0 prettier: ^2.7.1 prettier-plugin-svelte: ^2.8.0 + prosemirror-changeset: ~2.2.0 prosemirror-collab: ~1.3.0 + prosemirror-model: ~1.18.1 prosemirror-state: ~1.4.1 prosemirror-transform: ~1.7.0 + prosemirror-view: ~1.29.0 + rfc6902: ~5.0.1 sass: ^1.53.0 sass-loader: ^12.1.0 sharp: ~0.30.7 @@ -515,13 +524,16 @@ dependencies: '@rushstack/heft': 0.47.9 '@rushstack/heft-jest-plugin': 0.3.16_e810491d602256cb9138da3f42d797a2 '@tiptap/core': 2.0.0-beta.199 - '@tiptap/extension-collaboration': 2.0.0-beta.199_8e74c563ac0499ca22e9a6d9d9b7c0ed - '@tiptap/extension-collaboration-cursor': 2.0.0-beta.199_04364fb270bfa949258a810b562e2e26 + '@tiptap/extension-code-block': 2.0.0-beta.202_@tiptap+core@2.0.0-beta.199 + '@tiptap/extension-collaboration': 2.0.0-beta.199_a496b31b65360f0ca30cf7e3de4ce96e + '@tiptap/extension-collaboration-cursor': 2.0.0-beta.199_17c6db99c9b1488ff14e755dd0559594 + '@tiptap/extension-gapcursor': 2.0.0-beta.202_@tiptap+core@2.0.0-beta.199 + '@tiptap/extension-heading': 2.0.0-beta.202_@tiptap+core@2.0.0-beta.199 '@tiptap/extension-highlight': 2.0.0-beta.199_@tiptap+core@2.0.0-beta.199 '@tiptap/extension-link': 2.0.0-beta.199_@tiptap+core@2.0.0-beta.199 '@tiptap/extension-mention': 2.0.0-beta.199_c8f353cb3abc70247a8f6c56ebb87d62 '@tiptap/extension-placeholder': 2.0.0-beta.199_@tiptap+core@2.0.0-beta.199 - '@tiptap/extension-task-item': 2.0.0-beta.199_@tiptap+core@2.0.0-beta.199 + '@tiptap/extension-task-item': 2.0.0-beta.199_ec97b388f910dbe754a14ff2f0072b88 '@tiptap/extension-task-list': 2.0.0-beta.199_@tiptap+core@2.0.0-beta.199 '@tiptap/extension-typography': 2.0.0-beta.199_@tiptap+core@2.0.0-beta.199 '@tiptap/starter-kit': 2.0.0-beta.199 @@ -531,6 +543,7 @@ dependencies: '@types/cors': 2.8.12 '@types/crypto-js': 4.1.1 '@types/deep-equal': 1.0.1 + '@types/diff': 5.0.2 '@types/express': 4.17.13 '@types/express-fileupload': 1.2.2 '@types/faker': 5.5.9 @@ -566,6 +579,7 @@ dependencies: css-loader: 5.2.7_webpack@5.73.0 csv-parse: 5.1.0 deep-equal: 2.0.5 + diff: 5.1.0 dotenv: 16.0.1 dotenv-webpack: 7.1.1_webpack@5.73.0 elastic-apm-node: 3.26.0 @@ -606,9 +620,13 @@ dependencies: postcss-loader: 6.2.1_postcss@8.4.14+webpack@5.73.0 prettier: 2.7.1 prettier-plugin-svelte: 2.8.0_prettier@2.7.1+svelte@3.48.0 + prosemirror-changeset: 2.2.0 prosemirror-collab: 1.3.0 + prosemirror-model: 1.18.1 prosemirror-state: 1.4.1 prosemirror-transform: 1.7.0 + prosemirror-view: 1.29.0 + rfc6902: 5.0.1 sass: 1.53.0 sass-loader: 12.6.0_sass@1.53.0+webpack@5.73.0 sharp: 0.30.7 @@ -1612,7 +1630,7 @@ packages: prosemirror-schema-list: 1.2.2 prosemirror-state: 1.4.1 prosemirror-transform: 1.7.0 - prosemirror-view: 1.28.3 + prosemirror-view: 1.29.0 dev: false /@tiptap/extension-blockquote/2.0.0-beta.199_@tiptap+core@2.0.0-beta.199: @@ -1639,8 +1657,8 @@ packages: '@tiptap/core': 2.0.0-beta.199 dev: false - /@tiptap/extension-code-block/2.0.0-beta.199_@tiptap+core@2.0.0-beta.199: - resolution: {integrity: sha512-ZfftYE1kHA2pD46hXDkeYd1vuxp3bJLS854B2yHfw1cp3JVDjMXzm4Mzg7zLfr+YV1dT/N/fUfdCg38fqEUCyA==} + /@tiptap/extension-code-block/2.0.0-beta.202_@tiptap+core@2.0.0-beta.199: + resolution: {integrity: sha512-tfK9khIroGjsXQvk2K/9z1/UyQrB4+zghkjyK1xikzRmhgfOeqQzA0TDrFrz7ywFXmSFQ7GnnYAp+RW6r6wyUg==} peerDependencies: '@tiptap/core': ^2.0.0-beta.193 dependencies: @@ -1656,13 +1674,13 @@ packages: '@tiptap/core': 2.0.0-beta.199 dev: false - /@tiptap/extension-collaboration-cursor/2.0.0-beta.199_04364fb270bfa949258a810b562e2e26: + /@tiptap/extension-collaboration-cursor/2.0.0-beta.199_17c6db99c9b1488ff14e755dd0559594: resolution: {integrity: sha512-3LOfuSFYZz5CQOm+3rCKSm/hKXfP+KgW6aDYDfDTI3IKtMkC9b8FYa2ZoW5Qj/H1H+2m0+8aT6p9RIybtVi9jQ==} peerDependencies: '@tiptap/core': ^2.0.0-beta.193 dependencies: '@tiptap/core': 2.0.0-beta.199 - y-prosemirror: 1.0.20_aeff9fb8f06ec077e7a357cd76186471 + y-prosemirror: 1.0.20_0101a562c8137253afa9fb877870d27d transitivePeerDependencies: - prosemirror-model - prosemirror-state @@ -1671,21 +1689,6 @@ packages: - yjs dev: false - /@tiptap/extension-collaboration/2.0.0-beta.199_8e74c563ac0499ca22e9a6d9d9b7c0ed: - resolution: {integrity: sha512-ub3doQvy7o7YLwLDz8B/LK7zZ6aEy19C36FJbSXfr93Ws8FhVy2PYvgwBnRYPIifXrS8FVkNL5ULjsH+QlEd5Q==} - peerDependencies: - '@tiptap/core': ^2.0.0-beta.193 - dependencies: - '@tiptap/core': 2.0.0-beta.199 - prosemirror-state: 1.4.1 - y-prosemirror: 1.0.20_aeff9fb8f06ec077e7a357cd76186471 - transitivePeerDependencies: - - prosemirror-model - - prosemirror-view - - y-protocols - - yjs - dev: false - /@tiptap/extension-collaboration/2.0.0-beta.199_@tiptap+core@2.0.0-beta.199: resolution: {integrity: sha512-ub3doQvy7o7YLwLDz8B/LK7zZ6aEy19C36FJbSXfr93Ws8FhVy2PYvgwBnRYPIifXrS8FVkNL5ULjsH+QlEd5Q==} peerDependencies: @@ -1701,6 +1704,21 @@ packages: - yjs dev: false + /@tiptap/extension-collaboration/2.0.0-beta.199_a496b31b65360f0ca30cf7e3de4ce96e: + resolution: {integrity: sha512-ub3doQvy7o7YLwLDz8B/LK7zZ6aEy19C36FJbSXfr93Ws8FhVy2PYvgwBnRYPIifXrS8FVkNL5ULjsH+QlEd5Q==} + peerDependencies: + '@tiptap/core': ^2.0.0-beta.193 + dependencies: + '@tiptap/core': 2.0.0-beta.199 + prosemirror-state: 1.4.1 + y-prosemirror: 1.0.20_0101a562c8137253afa9fb877870d27d + transitivePeerDependencies: + - prosemirror-model + - prosemirror-view + - y-protocols + - yjs + dev: false + /@tiptap/extension-document/2.0.0-beta.199_@tiptap+core@2.0.0-beta.199: resolution: {integrity: sha512-l/3k9N2O4wIMQoN/SM3aIBwOhZ2KRxQoqGJfsbAUUwBURBDiT4N2VZaNiJC/w3xCVQXIxHSIlqtm9ZBcZeiH/Q==} peerDependencies: @@ -1718,8 +1736,8 @@ packages: prosemirror-dropcursor: 1.5.0 dev: false - /@tiptap/extension-gapcursor/2.0.0-beta.199_@tiptap+core@2.0.0-beta.199: - resolution: {integrity: sha512-0TDpDfDyay+IbD+wJMsBJ2c0Cq0NtllUOxbi0NPjjWW94Jrvs1yqUSzX4Qp9m5MW8qP24IV6krgZBM1JyQc6ng==} + /@tiptap/extension-gapcursor/2.0.0-beta.202_@tiptap+core@2.0.0-beta.199: + resolution: {integrity: sha512-jOPMPPnTfVuc5YpFTcQM42/cg1J3+OeHitYb1/vBMpaNinVijuafsK14xDoVP8+sydKVgtBzYkfP/faN82I9iA==} peerDependencies: '@tiptap/core': ^2.0.0-beta.193 dependencies: @@ -1735,8 +1753,8 @@ packages: '@tiptap/core': 2.0.0-beta.199 dev: false - /@tiptap/extension-heading/2.0.0-beta.199_@tiptap+core@2.0.0-beta.199: - resolution: {integrity: sha512-WGQ7ET2TBpldrD8JX37OXHXq05LU3OWItIVBs9nKGh4otZTUwPtwfOyMlFfA+IMfQif+ilwLGvUC6EHOw/LwxQ==} + /@tiptap/extension-heading/2.0.0-beta.202_@tiptap+core@2.0.0-beta.199: + resolution: {integrity: sha512-sF271jSWHgtoJLDNFLS7eyUcUStl7mBDQNJIENWVI+lFu2Ax8GmO7AoB74Q6L5Zaw4h73L6TAvaafHIXurz7tA==} peerDependencies: '@tiptap/core': ^2.0.0-beta.193 dependencies: @@ -1832,7 +1850,7 @@ packages: '@tiptap/core': 2.0.0-beta.199 prosemirror-model: 1.18.1 prosemirror-state: 1.4.1 - prosemirror-view: 1.28.3 + prosemirror-view: 1.29.0 dev: false /@tiptap/extension-strike/2.0.0-beta.199_@tiptap+core@2.0.0-beta.199: @@ -1852,6 +1870,16 @@ packages: '@tiptap/core': 2.0.0-beta.199 dev: false + /@tiptap/extension-task-item/2.0.0-beta.199_ec97b388f910dbe754a14ff2f0072b88: + resolution: {integrity: sha512-dvMgXr4B/V8dYvksLtbby3R2wM9zk3xdkOBuohTLQuRq73dK12Bh/h5xrl4cey8i/2tQBWgUfFiGVPsEUJjQCQ==} + peerDependencies: + '@tiptap/core': ^2.0.0-beta.193 + prosemirror-model: ^1.18.1 + dependencies: + '@tiptap/core': 2.0.0-beta.199 + prosemirror-model: 1.18.1 + dev: false + /@tiptap/extension-task-list/2.0.0-beta.199_@tiptap+core@2.0.0-beta.199: resolution: {integrity: sha512-//1bw2Wd4IYKxYLw3iaxBcd0/iFw1Jwc/Q1j41oBc5QTZDuRxhEO/5Gjy1UmEZsWhsH39bS2za4uMBX4DbHBUQ==} peerDependencies: @@ -1884,12 +1912,12 @@ packages: '@tiptap/extension-bold': 2.0.0-beta.199_@tiptap+core@2.0.0-beta.199 '@tiptap/extension-bullet-list': 2.0.0-beta.199_@tiptap+core@2.0.0-beta.199 '@tiptap/extension-code': 2.0.0-beta.199_@tiptap+core@2.0.0-beta.199 - '@tiptap/extension-code-block': 2.0.0-beta.199_@tiptap+core@2.0.0-beta.199 + '@tiptap/extension-code-block': 2.0.0-beta.202_@tiptap+core@2.0.0-beta.199 '@tiptap/extension-document': 2.0.0-beta.199_@tiptap+core@2.0.0-beta.199 '@tiptap/extension-dropcursor': 2.0.0-beta.199_@tiptap+core@2.0.0-beta.199 - '@tiptap/extension-gapcursor': 2.0.0-beta.199_@tiptap+core@2.0.0-beta.199 + '@tiptap/extension-gapcursor': 2.0.0-beta.202_@tiptap+core@2.0.0-beta.199 '@tiptap/extension-hard-break': 2.0.0-beta.199_@tiptap+core@2.0.0-beta.199 - '@tiptap/extension-heading': 2.0.0-beta.199_@tiptap+core@2.0.0-beta.199 + '@tiptap/extension-heading': 2.0.0-beta.202_@tiptap+core@2.0.0-beta.199 '@tiptap/extension-history': 2.0.0-beta.199_@tiptap+core@2.0.0-beta.199 '@tiptap/extension-horizontal-rule': 2.0.0-beta.199_@tiptap+core@2.0.0-beta.199 '@tiptap/extension-italic': 2.0.0-beta.199_@tiptap+core@2.0.0-beta.199 @@ -1908,7 +1936,7 @@ packages: '@tiptap/core': 2.0.0-beta.199 prosemirror-model: 1.18.1 prosemirror-state: 1.4.1 - prosemirror-view: 1.28.3 + prosemirror-view: 1.29.0 dev: false /@tootallnate/once/1.1.2: @@ -2052,6 +2080,10 @@ packages: resolution: {integrity: sha512-mMUu4nWHLBlHtxXY17Fg6+ucS/MnndyOWyOe7MmwkoMYxvfQU2ajtRaEvqSUv+aVkMqH/C0NCI8UoVfRNQ10yg==} dev: false + /@types/diff/5.0.2: + resolution: {integrity: sha512-uw8eYMIReOwstQ0QKF0sICefSy8cNO/v7gOTiIy9SbwuHyEecJUm7qlgueOO5S1udZ5I/irVydHVwMchgzbKTg==} + dev: false + /@types/email-addresses/3.0.0: resolution: {integrity: sha512-jGUOSgpOEWhTH4tMCj56NZenkzER259nJ5NGRvxXld3X7Lai/lxC3QNfDM0rVGMkj+WhANMpvIf195tgwnE7wQ==} deprecated: This is a stub types definition for email-addresses (https://github.com/jackbowman/email-addresses). email-addresses provides its own type definitions, so you don't need @types/email-addresses installed! @@ -4098,6 +4130,11 @@ packages: engines: {node: '>=0.3.1'} dev: false + /diff/5.1.0: + resolution: {integrity: sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==} + engines: {node: '>=0.3.1'} + dev: false + /diffie-hellman/5.0.3: resolution: {integrity: sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==} dependencies: @@ -7949,6 +7986,12 @@ packages: resolution: {integrity: sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==} dev: false + /prosemirror-changeset/2.2.0: + resolution: {integrity: sha512-QM7ohGtkpVpwVGmFb8wqVhaz9+6IUXcIQBGZ81YNAKYuHiFJ1ShvSzab4pKqTinJhwciZbrtBEk/2WsqSt2PYg==} + dependencies: + prosemirror-transform: 1.7.0 + dev: false + /prosemirror-collab/1.3.0: resolution: {integrity: sha512-+S/IJ69G2cUu2IM5b3PBekuxs94HO1CxJIWOFrLQXUaUDKL/JfBx+QcH31ldBlBXyDEUl+k3Vltfi1E1MKp2mA==} dependencies: @@ -7968,7 +8011,7 @@ packages: dependencies: prosemirror-state: 1.4.1 prosemirror-transform: 1.7.0 - prosemirror-view: 1.28.3 + prosemirror-view: 1.29.0 dev: false /prosemirror-gapcursor/1.3.1: @@ -7977,7 +8020,7 @@ packages: prosemirror-keymap: 1.2.0 prosemirror-model: 1.18.1 prosemirror-state: 1.4.1 - prosemirror-view: 1.28.3 + prosemirror-view: 1.29.0 dev: false /prosemirror-history/1.3.0: @@ -8022,8 +8065,8 @@ packages: prosemirror-model: 1.18.1 dev: false - /prosemirror-view/1.28.3: - resolution: {integrity: sha512-YnJxLRzIaCNEt3VKiy+PBxtpwsCbjrfiBKIgHJeqbKhdeP8bU2qL4ngdGmxp9K4+06cZG5bE9vipuhP+KUl+BQ==} + /prosemirror-view/1.29.0: + resolution: {integrity: sha512-bifVd5aD9uCNtpLL1AyhquG/cVbNZSv+ALBxTEGYv51a6OHDhq+aOuzqq4MermNdeBdT+5uyURXCALgzk0EN5g==} dependencies: prosemirror-model: 1.18.1 prosemirror-state: 1.4.1 @@ -8353,6 +8396,10 @@ packages: engines: {iojs: '>=1.0.0', node: '>=0.10.0'} dev: false + /rfc6902/5.0.1: + resolution: {integrity: sha512-tYGfLpKIq9X7lrt4o3IkD9w9bpeAtsejfAqWNR98AoxfTsZqCepKa8eDlRiX8QMiCOD9vMx0/YbKLx0G1nPi5w==} + dev: false + /rfdc/1.3.0: resolution: {integrity: sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==} dev: false @@ -10000,7 +10047,7 @@ packages: dev: false optional: true - /y-prosemirror/1.0.20_aeff9fb8f06ec077e7a357cd76186471: + /y-prosemirror/1.0.20_0101a562c8137253afa9fb877870d27d: resolution: {integrity: sha512-LVMtu3qWo0emeYiP+0jgNcvZkqhzE/otOoro+87q0iVKxy/sMKuiJZnokfJdR4cn9qKx0Un5fIxXqbAlR2bFkA==} peerDependencies: prosemirror-model: ^1.7.1 @@ -10010,7 +10057,9 @@ packages: yjs: ^13.3.2 dependencies: lib0: 0.2.52 + prosemirror-model: 1.18.1 prosemirror-state: 1.4.1 + prosemirror-view: 1.29.0 y-protocols: 1.0.5 yjs: 13.5.42 dev: false @@ -14491,25 +14540,30 @@ packages: dev: false file:projects/text-editor.tgz_13653a9d42656433759444fbd2afc848: - resolution: {integrity: sha512-nR7pmL9ssos3gvV+tv6lfjcG6IldNayfbzZFp5678fazvi5PDxm4FPboUlKFhwBIVFacbmc2GiP+H7PQASn8jg==, tarball: file:projects/text-editor.tgz} + resolution: {integrity: sha512-fTQmayD5wtd7FqRklhl13kDS5Y9tyBXfC5FAoJP+xUHbNk61+FzgKnmHFOjbsJVvgFVkag98AJA9xb9nHfjFzw==, tarball: file:projects/text-editor.tgz} id: file:projects/text-editor.tgz name: '@rush-temp/text-editor' version: 0.0.0 dependencies: '@tiptap/core': 2.0.0-beta.199 - '@tiptap/extension-collaboration': 2.0.0-beta.199_8e74c563ac0499ca22e9a6d9d9b7c0ed - '@tiptap/extension-collaboration-cursor': 2.0.0-beta.199_04364fb270bfa949258a810b562e2e26 + '@tiptap/extension-code-block': 2.0.0-beta.202_@tiptap+core@2.0.0-beta.199 + '@tiptap/extension-collaboration': 2.0.0-beta.199_a496b31b65360f0ca30cf7e3de4ce96e + '@tiptap/extension-collaboration-cursor': 2.0.0-beta.199_17c6db99c9b1488ff14e755dd0559594 + '@tiptap/extension-gapcursor': 2.0.0-beta.202_@tiptap+core@2.0.0-beta.199 + '@tiptap/extension-heading': 2.0.0-beta.202_@tiptap+core@2.0.0-beta.199 '@tiptap/extension-highlight': 2.0.0-beta.199_@tiptap+core@2.0.0-beta.199 '@tiptap/extension-link': 2.0.0-beta.199_@tiptap+core@2.0.0-beta.199 '@tiptap/extension-mention': 2.0.0-beta.199_c8f353cb3abc70247a8f6c56ebb87d62 '@tiptap/extension-placeholder': 2.0.0-beta.199_@tiptap+core@2.0.0-beta.199 - '@tiptap/extension-task-item': 2.0.0-beta.199_@tiptap+core@2.0.0-beta.199 + '@tiptap/extension-task-item': 2.0.0-beta.199_ec97b388f910dbe754a14ff2f0072b88 '@tiptap/extension-task-list': 2.0.0-beta.199_@tiptap+core@2.0.0-beta.199 '@tiptap/extension-typography': 2.0.0-beta.199_@tiptap+core@2.0.0-beta.199 '@tiptap/starter-kit': 2.0.0-beta.199 '@tiptap/suggestion': 2.0.0-beta.199_@tiptap+core@2.0.0-beta.199 + '@types/diff': 5.0.2 '@typescript-eslint/eslint-plugin': 5.42.0_8b6083565a963e7484743e25607ce89c '@typescript-eslint/parser': 5.42.0_eslint@8.26.0+typescript@4.7.4 + diff: 5.1.0 emoji-regex: 10.1.0 eslint: 8.26.0 eslint-config-standard-with-typescript: 23.0.0_35db0d754f34ccffcc0e5a361183072e @@ -14520,9 +14574,13 @@ packages: eslint-plugin-svelte3: 4.0.0_eslint@8.26.0+svelte@3.48.0 prettier: 2.7.1 prettier-plugin-svelte: 2.8.0_prettier@2.7.1+svelte@3.48.0 + prosemirror-changeset: 2.2.0 prosemirror-collab: 1.3.0 + prosemirror-model: 1.18.1 prosemirror-state: 1.4.1 prosemirror-transform: 1.7.0 + prosemirror-view: 1.29.0 + rfc6902: 5.0.1 sass: 1.53.0 svelte: 3.48.0 svelte-check: 2.8.0_818cdd0cbd32f329a17bf389fa6ed6e6 @@ -14538,8 +14596,6 @@ packages: - node-sass - postcss - postcss-load-config - - prosemirror-model - - prosemirror-view - pug - stylus - sugarss @@ -14855,7 +14911,7 @@ packages: dev: false file:projects/workbench-resources.tgz_1e3963ebf0ceeb25b2fa6a1cc87e253c: - resolution: {integrity: sha512-4m7K7jULDi99Dty4n+D3lmGmbi+r/NKlp39QbY1b+CJyKgm2VMXciabLOpOpZsCDx42W3fSmRXaVpgRmkuwDew==, tarball: file:projects/workbench-resources.tgz} + resolution: {integrity: sha512-ff56NyjSj5PgQmkJbToL25SlAvhBjTKIJ1Zu6cTQuAwoji1/c+K5VC5xNj5lgE+ZD0hVkGqKUHZwmZNK5ti8RA==, tarball: file:projects/workbench-resources.tgz} id: file:projects/workbench-resources.tgz name: '@rush-temp/workbench-resources' version: 0.0.0 diff --git a/packages/panel/src/components/Panel.svelte b/packages/panel/src/components/Panel.svelte index 3278b46147..facc6119bd 100644 --- a/packages/panel/src/components/Panel.svelte +++ b/packages/panel/src/components/Panel.svelte @@ -34,9 +34,18 @@ export let isSub: boolean = true export let isAside: boolean = true export let isCustomAttr: boolean = true + export let floatAside = false - + {#if $$slots.navigator}
diff --git a/packages/text-editor/lang/en.json b/packages/text-editor/lang/en.json index c45aabb96d..dbc19cee53 100644 --- a/packages/text-editor/lang/en.json +++ b/packages/text-editor/lang/en.json @@ -22,6 +22,7 @@ "Objects": "Objects", "Food": "Food", "FullDescription": "Full description", - "NoFullDescription": "There are no detailed description" + "NoFullDescription": "There are no detailed description", + "EnableDiffMode": "Diff mode" } } diff --git a/packages/text-editor/lang/ru.json b/packages/text-editor/lang/ru.json index 16cf3c7f0d..d2afcc2c50 100644 --- a/packages/text-editor/lang/ru.json +++ b/packages/text-editor/lang/ru.json @@ -22,6 +22,7 @@ "Objects": "Объекты", "Food": "Еда", "FullDescription": "Детальное описание", - "NoFullDescription": "Нет детального описания" + "NoFullDescription": "Нет детального описания", + "EnableDiffMode": "Режим сравнения" } } diff --git a/packages/text-editor/package.json b/packages/text-editor/package.json index f67e92d140..b11a4cbb97 100644 --- a/packages/text-editor/package.json +++ b/packages/text-editor/package.json @@ -27,7 +27,8 @@ "eslint": "^8.26.0", "prettier": "^2.7.1", "svelte-check": "^2.8.0", - "typescript": "^4.3.5" + "typescript": "^4.3.5", + "@types/diff": "~5.0.2" }, "dependencies": { "@hcengineering/presentation": "~0.6.2", @@ -53,6 +54,14 @@ "prosemirror-state": "~1.4.1", "prosemirror-transform": "~1.7.0", "yjs": "^13.5.42", - "y-websocket": "^1.4.5" + "y-websocket": "^1.4.5", + "prosemirror-changeset": "~2.2.0", + "prosemirror-model": "~1.18.1", + "prosemirror-view": "~1.29.0", + "rfc6902": "~5.0.1", + "diff": "~5.1.0", + "@tiptap/extension-code-block": "~2.0.0-beta.200", + "@tiptap/extension-gapcursor": "~2.0.0-beta.200", + "@tiptap/extension-heading": "~2.0.0-beta.200" } } diff --git a/packages/text-editor/src/changeSet.ts b/packages/text-editor/src/changeSet.ts new file mode 100644 index 0000000000..3c1862fc45 --- /dev/null +++ b/packages/text-editor/src/changeSet.ts @@ -0,0 +1,168 @@ +import { Extension, Mark, mergeAttributes } from '@tiptap/core' +import { ChangeSet } from 'prosemirror-changeset' +import { Plugin } from 'prosemirror-state' + +export interface ChangeHighlightOptions { + multicolor: boolean + HTMLAttributes: Record +} + +declare module '@tiptap/core' { + interface Commands { + changeHighlight: { + /** + * Set a highlight mark + */ + setChangeHighlight: (attributes?: { color: string }) => ReturnType + /** + * Toggle a highlight mark + */ + toggleChangeHighlight: (attributes?: { color: string }) => ReturnType + /** + * Unset a highlight mark + */ + unsetChangeHighlight: () => ReturnType + } + } +} + +export const ChangeHighlight = Mark.create({ + name: 'changeHighlight', + + addOptions () { + return { + multicolor: true, + HTMLAttributes: { + changeColor: 'yellow' + } + } + }, + + addAttributes () { + if (!this.options.multicolor) { + return {} + } + + return { + color: { + default: null, + parseHTML: (element) => element.getAttribute('color'), + renderHTML: (attributes) => { + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + if (!attributes.color) { + return {} + } + + const color = attributes.color as string + + return { + color, + style: `border-top: 1px solid ${color}; border-bottom: 1px solid ${color}; border-radius: 2px;` + } + } + } + } + }, + + parseHTML () { + return [ + { + tag: 'cmark' + } + ] + }, + + renderHTML ({ HTMLAttributes }) { + return ['cmark', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + }, + + addCommands () { + return { + setChangeHighlight: + (attributes) => + ({ commands }) => { + return commands.setMark(this.name, attributes) + }, + toggleChangeHighlight: + (attributes) => + ({ commands }) => { + return commands.toggleMark(this.name, attributes) + }, + unsetChangeHighlight: + () => + ({ commands }) => { + return commands.unsetMark(this.name) + } + } + } +}) + +export interface ChangesetExtensionOptions { + isSuggestMode: () => boolean +} + +export const ChangesetExtension = Extension.create({ + // addInputRules () { + // return [changeSetRule] + // }, + addProseMirrorPlugins () { + return [ + new Plugin({ + appendTransaction: (_transactions, oldState, newState) => { + // no changes + if (newState.doc === oldState.doc) { + return + } + const tr = newState.tr + if (this.options.isSuggestMode()) { + let changes = ChangeSet.create(oldState.doc) + + for (const tr of _transactions) { + changes = changes.addSteps(tr.doc, tr.mapping.maps, undefined) + } + + for (const r of changes.changes) { + const from = r.fromB + const to = r.toB + if (r.inserted.length > 0 && from !== to) { + tr.addMark(from, to, newState.schema.marks.changeHighlight.create({ color: 'lightblue' })) + } + + if (r.deleted.length > 0) { + const deletedText = oldState.doc.textBetween(r.fromA, r.toA) + tr.insertText(deletedText, from) + tr.addMark( + from, + from + deletedText.length, + newState.schema.marks.changeHighlight.create({ color: 'orange' }) + ) + } + } + } + return tr + } + }) + ] + }, + onCreate () { + // The editor is ready. + }, + onUpdate () { + // The content has changed. + }, + // onSelectionUpdate ({ editor }) { + // // The selection has changed. + // }, + onTransaction ({ transaction }) { + // The editor state has changed. + }, + onFocus ({ event }) { + // The editor is focused. + }, + onBlur ({ event }) { + // The editor isn’t focused anymore. + }, + onDestroy () { + // The editor is being destroyed. + } +}) diff --git a/packages/text-editor/src/components/CollaborationDiffViewer.svelte b/packages/text-editor/src/components/CollaborationDiffViewer.svelte new file mode 100644 index 0000000000..07c5f9bcc0 --- /dev/null +++ b/packages/text-editor/src/components/CollaborationDiffViewer.svelte @@ -0,0 +1,278 @@ + + + +
+ {#if comparedVersion !== undefined} +
+
+
+ { + showDiff = !showDiff + editor.chain().focus() + }} + /> +
+
+ {/if} +
+
+
+
+ + diff --git a/packages/text-editor/src/components/CollaboratorEditor.svelte b/packages/text-editor/src/components/CollaboratorEditor.svelte index 298bbffa6f..844277e39e 100644 --- a/packages/text-editor/src/components/CollaboratorEditor.svelte +++ b/packages/text-editor/src/components/CollaboratorEditor.svelte @@ -15,38 +15,45 @@ // -->
- {#if isFormatting && !readonly} -
- - - - -
- - -
- -
- - -
- {/if} +
+ {#if isFormatting && !readonly} +
+ + + + + + +
+ + +
+ +
+ + +
+ {/if} +
+ {#if comparedVersion !== undefined} +
+ { + showDiff = !showDiff + editor.chain().focus() + }} + /> +
+ {/if} +
@@ -325,7 +461,7 @@ diff --git a/packages/text-editor/src/components/StyleButton.svelte b/packages/text-editor/src/components/StyleButton.svelte index 72146dc315..a5389f9169 100644 --- a/packages/text-editor/src/components/StyleButton.svelte +++ b/packages/text-editor/src/components/StyleButton.svelte @@ -15,15 +15,12 @@ diff --git a/packages/text-editor/src/components/diff/decorations.ts b/packages/text-editor/src/components/diff/decorations.ts new file mode 100644 index 0000000000..5507145fa4 --- /dev/null +++ b/packages/text-editor/src/components/diff/decorations.ts @@ -0,0 +1,99 @@ +// +// Copyright © 2022 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public 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 https://www.eclipse.org/legal/epl-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. +// + +import { Markup } from '@hcengineering/core' +import { Editor } from '@tiptap/core' +import { ChangeSet } from 'prosemirror-changeset' +import { DOMParser, Node, Schema } from 'prosemirror-model' +import { Decoration, DecorationSet } from 'prosemirror-view' +import { recreateTransform } from './recreate' + +/** + * @public + */ +export function createDocument (schema: Schema, content: Markup): Node { + const wrappedValue = `${content}` + + const body = new window.DOMParser().parseFromString(wrappedValue, 'text/html').body + + return DOMParser.fromSchema(schema).parse(body) +} + +/** + * @public + */ +export function calculateDecorations ( + editor?: Editor, + oldContent?: string, + comparedVersion?: Markup +): + | { + decorations: DecorationSet + oldContent: string + } + | undefined { + try { + if (editor === undefined || editor.schema === undefined) { + return + } + if (comparedVersion === undefined) { + return + } + const schema = editor.schema + const docOld = createDocument(schema, comparedVersion) + const docNew = editor.state.doc + + const c = editor.getHTML() + if (c === oldContent) { + return + } + + const tr = recreateTransform(docOld, docNew) + const changeSet = ChangeSet.create(docOld).addSteps(tr.doc, tr.mapping.maps, undefined) + const changes = changeSet.changes + + const decorations: Decoration[] = [] + + function lintIcon (color: string): any { + const icon = document.createElement('div') + icon.className = `lint-icon ${color}` + return icon + } + + function deleted (prob: any): any { + const icon = document.createElement('span') + icon.className = 'deletion' + icon.innerText = prob + return icon + } + changes.forEach((change) => { + if (change.inserted.length > 0) { + decorations.push(Decoration.inline(change.fromB, change.toB, { class: 'diff insertion' }, {})) + decorations.push(Decoration.widget(change.fromB, lintIcon('add'))) + } + + if (change.deleted.length > 0) { + const cont = docOld.textBetween(change.fromA, change.toA) + decorations.push(Decoration.widget(change.fromB, deleted(cont))) + decorations.push(Decoration.widget(change.fromB, lintIcon('delete'))) + } + }) + if (decorations.length > 0) { + return { decorations: DecorationSet.empty.add(docNew, decorations), oldContent: c } + } + } catch (error: any) { + console.error(error) + } +} diff --git a/packages/text-editor/src/components/diff/diff.ts b/packages/text-editor/src/components/diff/diff.ts new file mode 100644 index 0000000000..7b2187751c --- /dev/null +++ b/packages/text-editor/src/components/diff/diff.ts @@ -0,0 +1,163 @@ +// +// Copyright © 2022 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public 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 https://www.eclipse.org/legal/epl-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. +// + +import { Diff, diffAny, Operation } from 'rfc6902/diff' +import { Pointer } from 'rfc6902/pointer' + +interface ArrayOperation { + op: 'add' | 'remove' | 'replace' + index: number + value?: any + original?: number +} +interface MemoValue { + operations: ArrayOperation[] + cost: number +} + +/** + * @public + * Modification of {diffArray} from rfc6902/diff, with respect to prosemirror model. + */ +export function diffArraysPM (input: any, output: any, ptr: Pointer, diff: Diff = diffAny): any { + // set up cost matrix (very simple initialization: just a map) + const memo: Record = { + '0,0': { operations: [], cost: 0 } + } + /** + Calculate the cheapest sequence of operations required to get from + input.slice(0, i) to output.slice(0, j). + There may be other valid sequences with the same cost, but none cheaper. + + @param i The row in the layout above + @param j The column in the layout above + @returns An object containing a list of operations, along with the total cost + of applying them (+1 for each add/remove/replace operation) + */ + function dist (i: number, j: number): MemoValue { + // memoized + const memoKey = `${i},${j}` + let memoized = memo[memoKey] + if (memoized === undefined) { + // TODO: this !diff(...).length usage could/should be lazy + if (i > 0 && j > 0 && diff(input[i - 1], output[j - 1], ptr.add(String(i - 1))).length === 0) { + // equal (no operations => no cost) + memoized = dist(i - 1, j - 1) + } else { + const alternatives: MemoValue[] = [] + if (i > 0) { + // NOT topmost row + const removeBase = dist(i - 1, j) + const removeOperation: ArrayOperation = { + op: 'remove', + index: i - 1 + } + alternatives.push(appendArrayOperation(removeBase, removeOperation)) + } + if (j > 0) { + // NOT leftmost column + const addBase: MemoValue = dist(i, j - 1) + const addOperation: ArrayOperation = { + op: 'add', + index: i - 1, + value: output[j - 1] + } + alternatives.push(appendArrayOperation(addBase, addOperation)) + } + if (i > 0 && j > 0) { + // TABLE MIDDLE + // supposing we replaced it, compute the rest of the costs: + const replaceBase = dist(i - 1, j - 1) + // okay, the general plan is to replace it, but we can be smarter, + // recursing into the structure and replacing only part of it if + // possible, but to do so we'll need the original value + const replaceOperation: ArrayOperation = { + op: 'replace', + index: i - 1, + original: input[i - 1], + value: output[j - 1] + } + // Replace only if simple or object's with type equal + const io = input[i - 1] + const jo = output[j - 1] + if ( + (typeof io !== 'object' && typeof jo !== 'object') || + (typeof io === 'object' && + typeof jo === 'object' && + io.type === jo.type && + Array.isArray(io.content) && + Array.isArray(jo.content)) + ) { + alternatives.push(appendArrayOperation(replaceBase, replaceOperation)) + } + } + // the only other case, i === 0 && j === 0, has already been memoized + // the meat of the algorithm: + // sort by cost to find the lowest one (might be several ties for lowest) + // [4, 6, 7, 1, 2].sort((a, b) => a - b) -> [ 1, 2, 4, 6, 7 ] + const best = alternatives.sort(function (a, b) { + return a.cost - b.cost + })[0] + memoized = best + } + memo[memoKey] = memoized + } + return memoized + } + // handle weird objects masquerading as Arrays that don't have proper length + // properties by using 0 for everything but positive numbers + const inputLength: number = isNaN(input.length) || input.length <= 0 ? 0 : input.length + const outputLength: number = isNaN(output.length) || output.length <= 0 ? 0 : output.length + const arrayOperations = dist(inputLength, outputLength).operations + const paddedOperations = arrayOperations.reduce( + function (_a: any, arrayOperation: ArrayOperation) { + const operations: Operation[] = _a[0] + const padding: number = _a[1] + if (arrayOperation.op === 'add') { + const paddedIndex = arrayOperation.index + 1 + padding + const indexToken = paddedIndex < inputLength + padding ? String(paddedIndex) : '-' + const operation: Operation = { + op: arrayOperation.op, + path: ptr.add(indexToken).toString(), + value: arrayOperation.value + } + // padding++ // maybe only if array_operation.index > -1 ? + return [operations.concat(operation), padding + 1] + } else if (arrayOperation.op === 'remove') { + const operation: Operation = { + op: arrayOperation.op, + path: ptr.add(String(arrayOperation.index + padding)).toString() + } + // padding-- + return [operations.concat(operation), padding - 1] + } else { + // replace + const replacePtr = ptr.add(String(arrayOperation.index + padding)) + const replaceOperations = diff(arrayOperation.original, arrayOperation.value, replacePtr) + return [operations.concat.apply(operations, replaceOperations), padding] + } + }, + [[], 0] + )[0] + return paddedOperations +} + +function appendArrayOperation (base: MemoValue, operation: ArrayOperation): MemoValue { + return { + // the new operation must be pushed on the end + operations: base.operations.concat(operation), + cost: base.cost + 1 + } +} diff --git a/packages/text-editor/src/components/diff/recreate.ts b/packages/text-editor/src/components/diff/recreate.ts new file mode 100644 index 0000000000..4c5b318f6c --- /dev/null +++ b/packages/text-editor/src/components/diff/recreate.ts @@ -0,0 +1,331 @@ +// +// Copyright © 2022 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public 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 https://www.eclipse.org/legal/epl-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. +// +// Parts of source code is taken from https://github.com/sueddeutsche/prosemirror-recreate-transform +// based on Apache License 2.0 +// + +import { Change, diffWordsWithSpace } from 'diff' +import { Node, Schema } from 'prosemirror-model' +import { ReplaceStep, Step, Transform } from 'prosemirror-transform' +import { applyPatch, createPatch, Operation } from 'rfc6902' +import { Pointer } from 'rfc6902/pointer' +import { diffArraysPM } from './diff' + +/** + * @public + */ +export function getReplaceStep (fromDoc: Node, toDoc: Node): ReplaceStep | undefined { + const start = toDoc.content.findDiffStart(fromDoc.content) + if (start !== null) { + const pos = toDoc.content.findDiffEnd(fromDoc.content) + if (pos != null) { + return getReplaceStepOverlap(pos, start, fromDoc, toDoc) + } + } +} +function getReplaceStepOverlap (pos: { a: number, b: number }, start: number, fromDoc: Node, toDoc: Node): ReplaceStep { + let { a: endA, b: endB } = pos + const overlap = start - Math.min(endA, endB) + if (overlap > 0) { + if (fromDoc.resolve(start - overlap).depth < toDoc.resolve(endA + overlap).depth) { + start -= overlap + } else { + endA += overlap + endB += overlap + } + } + return new ReplaceStep(start, endB, toDoc.slice(start, endA)) +} + +export function simplifyTransform (tr: Transform): Transform | undefined { + if (tr.steps.length === 0) { + return undefined + } + + const newTr = new Transform(tr.docs[0]) + const oldSteps = tr.steps.slice() + + while (oldSteps.length > 0) { + let step = oldSteps.shift() + while (oldSteps.length > 0 && step?.merge(oldSteps[0]) != null) { + step = simplifyStep(step, oldSteps.shift(), newTr) + } + if (step === undefined) { + return undefined + } + newTr.step(step) + } + return newTr +} + +function simplifyStep (step: Step | undefined, addedStep: Step | undefined, newTr: Transform): Step | undefined { + if (step != null && step instanceof ReplaceStep && addedStep != null && addedStep instanceof ReplaceStep) { + const stepA = step.apply(newTr.doc) + if (stepA.doc != null) { + const stepB = addedStep.apply(stepA.doc) + if (stepB.doc !== null) { + step = getReplaceStep(newTr.doc, stepB.doc) + } else { + step = undefined + } + } else { + step = undefined + } + } else if (addedStep !== undefined) { + step = step?.merge(addedStep) ?? undefined + } + return step +} + +function getFromPath (obj: any, path: string): any { + const pathParts = path.split('/') + pathParts.shift() // remove root + while (pathParts.length > 0) { + const property = pathParts.shift() + obj = obj[property ?? ''] + } + return obj +} + +function removeMarks (doc: Node): Node { + const tr = new Transform(doc) + tr.removeMark(0, doc.nodeSize - 2) + return tr.doc +} + +function clone (obj: any): any { + if (typeof obj === 'function') { + return obj + } + const result: any = Array.isArray(obj) ? [] : {} + for (const key in obj) { + // include prototype properties + const value = obj[key] + const type = {}.toString.call(value).slice(8, -1) + if (type === 'Array') { + result[key] = clone(value) + } else if (type === 'Object') { + result[key] = clone(value) + } else if (type === 'Date') { + result[key] = new Date(value.getTime()) + } else { + result[key] = value + } + } + return result +} + +export class StepTransform { + schema: Schema + tr: Transform + currentDoc: any + finalDoc: any + ops: Operation[] = [] + + constructor (readonly fromDoc: Node, readonly toDoc: Node) { + this.schema = fromDoc.type.schema + this.tr = new Transform(fromDoc) + } + + init (): Transform { + this.currentDoc = removeMarks(this.fromDoc).toJSON() + this.finalDoc = removeMarks(this.toDoc).toJSON() + this.ops = createPatch(this.currentDoc, this.finalDoc, (input: any, output: any, ptr: Pointer) => { + if (Array.isArray(input) && Array.isArray(output)) { + return diffArraysPM(input, output, ptr) + } + }) + this.recreateChangeContentSteps() + this.recreateChangeMarkSteps() + this.tr = simplifyTransform(this.tr) ?? this.tr + + return this.tr + } + + recreateChangeContentSteps (): void { + // First step: find content changing steps. + let ops = [] + while (this.ops.length > 0) { + // get next + let op = this.ops.shift() as Operation + ops.push(op) + + let toDoc + const afterStepJSON = clone(this.currentDoc) // working document receiving patches + const pathParts = op.path.split('/') + + // collect operations until we receive a valid document: + // apply ops-patches until a valid prosemirror document is retrieved, + // then try to create a transformation step or retry with next operation + while (toDoc == null) { + applyPatch(afterStepJSON, [op]) + + try { + toDoc = this.schema.nodeFromJSON(afterStepJSON) + toDoc.check() + } catch (error: any) { + toDoc = null + if (this.ops.length > 0) { + op = this.ops.shift() as Operation + ops.push(op) + } else { + throw new Error(`No valid diff possible applying ${op.path} ${JSON.stringify(error, undefined, 2)}`) + } + } + } + + // apply operation (ignoring afterStepJSON) + if (ops.length === 1 && (pathParts.includes('attrs') || pathParts.includes('type'))) { + // Node markup is changing + this.addSetNodeMarkup() // a lost update is ignored + ops = [] + } else if (ops.length === 1 && op.op === 'replace' && pathParts[pathParts.length - 1] === 'text') { + // Text is being replaced, we apply text diffing to find the smallest possible diffs. + this.addReplaceTextSteps(op, afterStepJSON) + ops = [] + } else if (this.addReplaceStep(toDoc, afterStepJSON)) { + // operations have been applied + ops = [] + } + } + } + + addSetNodeMarkup (): boolean { + const fromDoc = this.schema.nodeFromJSON(this.currentDoc) + const toDoc = this.schema.nodeFromJSON(this.finalDoc) + const start = toDoc.content.findDiffStart(fromDoc.content) + // @note start is the same (first) position for current and target document + const fromNode = fromDoc.nodeAt(start ?? 0) + const toNode = toDoc.nodeAt(start ?? 0) + + if (start != null) { + // @note this completly updates all attributes in one step, by completely replacing node + const nodeType = fromNode?.type === toNode?.type ? null : toNode?.type + try { + this.tr.setNodeMarkup(start, nodeType, toNode?.attrs, toNode?.marks) + } catch (e: any) { + if (nodeType != null && (e.message as string).includes('Invalid content')) { + // @todo add test-case for this scenario + if (fromNode != null && toNode != null) { + this.tr.replaceWith(start, start + fromNode.nodeSize, toNode) + } + } else { + throw e + } + } + this.currentDoc = removeMarks(this.tr.doc).toJSON() + // setting the node markup may have invalidated the following ops, so we calculate them again. + this.ops = createPatch(this.currentDoc, this.finalDoc) + return true + } + return false + } + + recreateChangeMarkSteps (): void { + // Now the documents should be the same, except their marks, so everything should map 1:1. + // Second step: Iterate through the toDoc and make sure all marks are the same in tr.doc + this.toDoc.descendants((tNode, tPos) => { + if (!tNode.isInline) { + return true + } + + this.tr.doc.nodesBetween(tPos, tPos + tNode.nodeSize, (fNode, fPos) => { + if (!fNode.isInline) { + return true + } + const from = Math.max(tPos, fPos) + const to = Math.min(tPos + tNode.nodeSize, fPos + fNode.nodeSize) + fNode.marks.forEach((nodeMark) => { + if (!nodeMark.isInSet(tNode.marks)) { + this.tr.removeMark(from, to, nodeMark) + } + }) + tNode.marks.forEach((nodeMark) => { + if (!nodeMark.isInSet(fNode.marks)) { + this.tr.addMark(from, to, nodeMark) + } + }) + }) + }) + } + + addReplaceStep (toDoc: Node, afterStepJSON: any): boolean { + const fromDoc = this.schema.nodeFromJSON(this.currentDoc) + const step = getReplaceStep(fromDoc, toDoc) + + if (step == null) { + return false + } else if (this.tr.maybeStep(step).failed === null) { + this.currentDoc = afterStepJSON + return true // @change previously null + } + + throw new Error('No valid step found.') + } + + addReplaceTextSteps (op: any, afterStepJSON: any): void { + // We find the position number of the first character in the string + const op1 = { ...op, value: 'xx' } + const op2 = { ...op, value: 'yy' } + const afterOP1JSON = clone(this.currentDoc) + const afterOP2JSON = clone(this.currentDoc) + applyPatch(afterOP1JSON, [op1]) + applyPatch(afterOP2JSON, [op2]) + const op1Doc = this.schema.nodeFromJSON(afterOP1JSON) + const op2Doc = this.schema.nodeFromJSON(afterOP2JSON) + + // get text diffs + const finalText = op.value + const currentText = getFromPath(this.currentDoc, op.path) + const textDiffs = diffWordsWithSpace(currentText, finalText) + + let offset = op1Doc.content.findDiffStart(op2Doc.content) as number + const marks = op1Doc.resolve(offset + 1).marks() + + while (textDiffs.length > 0) { + const diff = textDiffs.shift() as Change + + if (diff.added === true) { + const textNode = this.schema.nodeFromJSON({ type: 'text', text: diff.value }).mark(marks) + + if (textDiffs.length > 0 && textDiffs[0].removed === true) { + const nextDiff = textDiffs.shift() as Change + this.tr.replaceWith(offset, offset + nextDiff.value.length, textNode) + } else { + this.tr.insert(offset, textNode) + } + offset += diff.value.length + } else if (diff.removed === true) { + if (textDiffs.length > 0 && textDiffs[0].added === true) { + const nextDiff = textDiffs.shift() as Change + const textNode = this.schema.nodeFromJSON({ type: 'text', text: nextDiff.value }).mark(marks) + this.tr.replaceWith(offset, offset + diff.value.length, textNode) + offset += nextDiff.value.length + } else { + this.tr.delete(offset, offset + diff.value.length) + } + } else { + offset += diff.value.length + } + } + + this.currentDoc = afterStepJSON + } +} + +export function recreateTransform (fromDoc: Node, toDoc: Node): Transform { + const recreator = new StepTransform(fromDoc, toDoc) + return recreator.init() +} diff --git a/packages/text-editor/src/components/icons/Header.svelte b/packages/text-editor/src/components/icons/Header.svelte new file mode 100644 index 0000000000..d218d0f882 --- /dev/null +++ b/packages/text-editor/src/components/icons/Header.svelte @@ -0,0 +1,8 @@ + + + + + diff --git a/packages/text-editor/src/index.ts b/packages/text-editor/src/index.ts index c5d1f42051..f1902b6239 100644 --- a/packages/text-editor/src/index.ts +++ b/packages/text-editor/src/index.ts @@ -26,6 +26,7 @@ export { default as TextEditor } from './components/TextEditor.svelte' export { default as EmojiPopup } from './components/EmojiPopup.svelte' export { default as FullDescriptionBox } from './components/FullDescriptionBox.svelte' export { default as CollaboratorEditor } from './components/CollaboratorEditor.svelte' +export { default as CollaborationDiffViewer } from './components/CollaborationDiffViewer.svelte' export { default } from './plugin' export * from './types' diff --git a/packages/text-editor/src/plugin.ts b/packages/text-editor/src/plugin.ts index b4e5b642f1..acd83f45d4 100644 --- a/packages/text-editor/src/plugin.ts +++ b/packages/text-editor/src/plugin.ts @@ -54,6 +54,7 @@ export default plugin(textEditorId, { Food: '' as IntlString, Objects: '' as IntlString, FullDescription: '' as IntlString, - NoFullDescription: '' as IntlString + NoFullDescription: '' as IntlString, + EnableDiffMode: '' as IntlString } }) diff --git a/packages/text-editor/src/types.ts b/packages/text-editor/src/types.ts index fb418a6972..03fbc2b9a6 100644 --- a/packages/text-editor/src/types.ts +++ b/packages/text-editor/src/types.ts @@ -34,7 +34,8 @@ export const FORMAT_MODES = [ 'bulletList', 'blockquote', 'code', - 'codeBlock' + 'codeBlock', + 'heading' ] as const export type FormatMode = typeof FORMAT_MODES[number] diff --git a/packages/text-editor/src/uniqId.ts b/packages/text-editor/src/uniqId.ts deleted file mode 100644 index 9df34682b6..0000000000 --- a/packages/text-editor/src/uniqId.ts +++ /dev/null @@ -1,61 +0,0 @@ -// -// Copyright © 2022 Hardcore Engineering Inc. -// -// Licensed under the Eclipse Public 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 https://www.eclipse.org/legal/epl-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. -// -import { generateId } from '@hcengineering/core' -import { Node } from '@tiptap/core' -import { Plugin } from 'prosemirror-state' - -export const UniqId = Node.create({ - name: 'blockId', - - addGlobalAttributes () { - return [ - { - types: ['heading', 'paragraph'], - attributes: { - uid: { - default: undefined, - rendered: false, - keepOnSplit: false - } - } - } - ] - }, - - addProseMirrorPlugins () { - return [ - new Plugin({ - appendTransaction: (_transactions, oldState, newState) => { - // no changes - if (newState.doc === oldState.doc) { - return - } - const tr = newState.tr - - newState.doc.descendants((node, pos, parent) => { - if (node.isBlock && parent === newState.doc && node.attrs?.uid === undefined) { - tr.setNodeMarkup(pos, undefined, { - ...node.attrs, - uid: generateId() - }) - } - }) - - return tr - } - }) - ] - } -}) diff --git a/packages/ui/src/components/Panel.svelte b/packages/ui/src/components/Panel.svelte index 644e484a01..fab96f7bec 100644 --- a/packages/ui/src/components/Panel.svelte +++ b/packages/ui/src/components/Panel.svelte @@ -29,6 +29,7 @@ export let isAside: boolean = true export let isFullSize: boolean = false export let withoutTitle: boolean = false + export let floatAside = false const dispatch = createEventDispatcher() @@ -39,7 +40,9 @@ $: twoRows = $deviceInfo.minWidth const checkPanel = (): void => { - if (panelWidth <= 900 && !asideFloat) asideFloat = true + if (floatAside) { + asideFloat = true + } else if (panelWidth <= 900 && !asideFloat) asideFloat = true else if (panelWidth > 900 && asideFloat) { asideFloat = false asideShown = false diff --git a/plugins/document-assets/lang/en.json b/plugins/document-assets/lang/en.json index b4d2856e0b..b02fa9b26c 100644 --- a/plugins/document-assets/lang/en.json +++ b/plugins/document-assets/lang/en.json @@ -46,6 +46,8 @@ "Approve": "Approve", "Reject": "Reject", "Approved": "Approved", - "Rejected": "Rejected" + "Rejected": "Rejected", + + "CompareTo": "Compare to..." } } \ No newline at end of file diff --git a/plugins/document-assets/lang/ru.json b/plugins/document-assets/lang/ru.json index 94f165fc7e..3a4eda5afe 100644 --- a/plugins/document-assets/lang/ru.json +++ b/plugins/document-assets/lang/ru.json @@ -46,6 +46,8 @@ "Approve": "Утвердить", "Reject": "Отказать", "Approved": "Утверждено", - "Rejected": "Отказано" + "Rejected": "Отказано", + + "CompareTo": "Сравнить с..." } } \ No newline at end of file diff --git a/plugins/document-resources/src/components/DocumentEditor.svelte b/plugins/document-resources/src/components/DocumentEditor.svelte index dcff548176..8c34b0d4aa 100644 --- a/plugins/document-resources/src/components/DocumentEditor.svelte +++ b/plugins/document-resources/src/components/DocumentEditor.svelte @@ -22,20 +22,32 @@ import document from '../plugin' import { CollaboratorEditor } from '@hcengineering/text-editor' + import { Markup } from '@hcengineering/core' export let object: DocumentVersion export let readonly = false export let initialContentId: string | undefined = undefined + export let suggestMode = false + export let comparedVersion: Markup | undefined = undefined const token = getMetadata(login.metadata.LoginToken) ?? '' const collaboratorURL = getMetadata(document.metadata.CollaboratorUrl) ?? '' + let editor: CollaboratorEditor + export function getHTML (): string { + return editor.getHTML() + } - +{#key comparedVersion} + +{/key} diff --git a/plugins/document-resources/src/components/EditDoc.svelte b/plugins/document-resources/src/components/EditDoc.svelte index 7938ac62b3..69a8d9884e 100644 --- a/plugins/document-resources/src/components/EditDoc.svelte +++ b/plugins/document-resources/src/components/EditDoc.svelte @@ -29,8 +29,9 @@ import notification from '@hcengineering/notification' import { Panel } from '@hcengineering/panel' import { getResource, translate } from '@hcengineering/platform' - import { createQuery, getClient, MessageViewer } from '@hcengineering/presentation' + import { createQuery, getClient } from '@hcengineering/presentation' import tags from '@hcengineering/tags' + import { CollaborationDiffViewer } from '@hcengineering/text-editor' import { Button, @@ -161,6 +162,7 @@ { sort: { version: 1 } } ) let version: DocumentVersion | undefined + let compareTo: DocumentVersion | undefined let info: any @@ -205,6 +207,25 @@ ) } + function selectCompareToVersion (event: MouseEvent): void { + showPopup( + SelectPopup, + { + value: [{ id: null, text: '-' }, ...info.slice(0, info.length - 1)], + placeholder: document.string.Version, + searchable: true + }, + eventToHTMLElement(event), + (res) => { + if (res != null) { + compareTo = versions.find((it) => it._id === res) + } else if (res === null) { + compareTo = undefined + } + } + ) + } + $: readonly = !documentObject?.authors.includes(currentUser.employee) let autoSelect = true @@ -213,8 +234,8 @@ let mode: ModelType = 'view' const modeLabels = { view: document.string.ViewMode, - edit: document.string.EditMode - // ,suggest: document.string.SuggestMode + edit: document.string.EditMode, + suggest: document.string.SuggestMode } function selectMode (event: MouseEvent): void { @@ -304,8 +325,6 @@ let processing = false - let content = '' - const updateRequests = async (kind: DocumentRequestKind): Promise => { processing = true if (documentObject === undefined) { @@ -335,7 +354,7 @@ if (version) { await client.update(version, { - content + content: editor.getHTML() }) } @@ -343,7 +362,7 @@ processing = false } - + let editor: DocumentEditor const updateState = async (state: DocumentVersionState): Promise => { processing = true if (documentObject === undefined) { @@ -377,6 +396,12 @@ processing = false } + async function switchToDraft (): Promise { + const requests = await client.findAll(document.class.DocumentRequest, { attachedTo: documentObject?._id }) + for (const r of requests) { + client.remove(r) + } + } {#if documentObject !== undefined} @@ -386,6 +411,7 @@ isAside={true} isSub={false} bind:innerWidth + floatAside={true} on:close={() => dispatch('close')} > @@ -410,6 +436,21 @@ {/if} + + @@ -465,6 +506,16 @@ icon={IconClose} size={'medium'} /> + {#if !readonly} +