From c2d46c778b869e62800d0869ef59ff10688db8ff Mon Sep 17 00:00:00 2001 From: siltomato <133386342+siltomato@users.noreply.github.com> Date: Thu, 2 Jan 2025 18:44:49 -0500 Subject: [PATCH] SF-3136 Upgrade to Quill v2 (#2925) --- .../ClientApp/angular.json | 2 +- .../ClientApp/package-lock.json | 114 ++++---- .../ClientApp/package.json | 6 +- .../ClientApp/src/app/app.module.ts | 4 +- .../checking/checking.component.spec.ts | 3 +- .../question-dialog.component.spec.ts | 3 +- .../ClientApp/src/app/core/models/text-doc.ts | 18 +- .../src/app/core/text-doc.service.spec.ts | 12 +- .../src/app/core/text-doc.service.ts | 11 +- .../ClientApp/src/app/shared/shared.module.ts | 9 +- .../ClientApp/src/app/shared/test-utils.ts | 3 +- .../src/app/shared/text/quill-scripture.ts | 273 ++++++++++-------- .../ClientApp/src/app/shared/text/segment.ts | 8 +- .../src/app/shared/text/text-view-model.ts | 93 +++--- .../src/app/shared/text/text.component.html | 1 - .../app/shared/text/text.component.spec.ts | 151 +++++----- .../src/app/shared/text/text.component.ts | 159 +++++----- .../ClientApp/src/app/shared/utils.spec.ts | 2 +- .../ClientApp/src/app/shared/utils.ts | 8 +- .../text-chooser-dialog.component.spec.ts | 8 +- .../draft-handling.service.spec.ts | 3 +- .../draft-handling.service.ts | 18 +- .../editor-draft.component.spec.ts | 2 +- .../editor-draft/editor-draft.component.ts | 11 +- .../editor-history.component.spec.ts | 4 +- .../editor-history.component.ts | 8 +- .../editor-history.service.spec.ts | 2 +- .../editor-history/editor-history.service.ts | 6 +- .../history-chooser.component.ts | 6 +- .../translate/editor/editor.component.spec.ts | 65 +++-- .../app/translate/editor/editor.component.ts | 66 +++-- .../translate/editor/suggestions.component.ts | 4 +- .../training-progress.component.spec.ts | 3 +- .../translate-overview.component.spec.ts | 3 +- .../ClientApp/src/typings/quill.d.ts | 107 ------- .../ClientApp/src/typings/rich-text.d.ts | 6 +- 36 files changed, 569 insertions(+), 633 deletions(-) delete mode 100644 src/SIL.XForge.Scripture/ClientApp/src/typings/quill.d.ts diff --git a/src/SIL.XForge.Scripture/ClientApp/angular.json b/src/SIL.XForge.Scripture/ClientApp/angular.json index 17b9455056..c5fbd46eca 100644 --- a/src/SIL.XForge.Scripture/ClientApp/angular.json +++ b/src/SIL.XForge.Scripture/ClientApp/angular.json @@ -52,7 +52,7 @@ "mnemonist/heap", "mingo", "papaparse", - "quill", + "quill-delta", "tinycolor2", "ts-md5" ], diff --git a/src/SIL.XForge.Scripture/ClientApp/package-lock.json b/src/SIL.XForge.Scripture/ClientApp/package-lock.json index 53d629c47d..e26507e790 100644 --- a/src/SIL.XForge.Scripture/ClientApp/package-lock.json +++ b/src/SIL.XForge.Scripture/ClientApp/package-lock.json @@ -41,13 +41,13 @@ "mingo": "^4.4.1", "ng-circle-progress": "^1.7.1", "ngx-cookie-service": "^18.0.0", - "ngx-quill": "^24.0.0", + "ngx-quill": "^26.0.10", "ngx-transloco-markup": "^4.0.0", "ngx-transloco-markup-router-link": "^4.0.0", "ot-json0": "^1.1.0", "papaparse": "^5.3.2", "process": "^0.11.10", - "quill": "^1.3.7", + "quill": "^2.0.3", "quill-cursors": "^3.1.2", "realtime-server": "file:../../RealtimeServer", "reconnecting-websocket": "^4.4.0", @@ -83,6 +83,7 @@ "@storybook/types": "^8.3.5", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.5.0", + "@testing-library/user-event": "^14.5.2", "@types/auth0-js": "^9.14.6", "@types/events": "^3.0.0", "@types/file-saver": "^2.0.7", @@ -92,7 +93,6 @@ "@types/lodash-es": "^4.17.6", "@types/node": "^22.7.5", "@types/papaparse": "^5.3.2", - "@types/quill": "^1.3.10", "@types/sharedb": "^1.0.24", "@types/tinycolor2": "^1.4.3", "@types/uuid": "8.3.4", @@ -8562,15 +8562,6 @@ "integrity": "sha512-oGk0gmhnEJK4Yyk+oI7EfXsLayXatCWPHary1MtcmbAifkobT9cM9yutG/hZKIseOU0MqbIwQ/u2nn/Gb+ltuQ==", "dev": true }, - "node_modules/@types/quill": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@types/quill/-/quill-1.3.10.tgz", - "integrity": "sha512-IhW3fPW+bkt9MLNlycw8u8fWb7oO7W5URC9MfZYHBlA24rex9rs23D5DETChu1zvgVdc5ka64ICjJOgQMr6Shw==", - "dev": true, - "dependencies": { - "parchment": "^1.1.2" - } - }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", @@ -10622,6 +10613,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dev": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -11908,6 +11900,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", + "dev": true, "dependencies": { "is-arguments": "^1.1.1", "is-date-object": "^1.0.5", @@ -12008,6 +12001,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -12033,6 +12027,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", @@ -12651,6 +12646,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dev": true, "dependencies": { "get-intrinsic": "^1.2.4" }, @@ -12662,6 +12658,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, "engines": { "node": ">= 0.4" } @@ -13716,7 +13713,8 @@ "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true }, "node_modules/external-editor": { "version": "3.1.0", @@ -13764,8 +13762,7 @@ "node_modules/fast-diff": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "dev": true + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==" }, "node_modules/fast-glob": { "version": "3.3.2", @@ -14509,6 +14506,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -14535,6 +14533,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -14572,6 +14571,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dev": true, "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2", @@ -14790,6 +14790,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, "dependencies": { "get-intrinsic": "^1.1.3" }, @@ -14890,6 +14891,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, "dependencies": { "es-define-property": "^1.0.0" }, @@ -14901,6 +14903,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -14913,6 +14916,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -14924,6 +14928,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -14964,6 +14969,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -15694,6 +15700,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -15811,6 +15818,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, "dependencies": { "has-tostringtag": "^1.0.0" }, @@ -16028,6 +16036,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -20512,18 +20521,18 @@ } }, "node_modules/ngx-quill": { - "version": "24.0.5", - "resolved": "https://registry.npmjs.org/ngx-quill/-/ngx-quill-24.0.5.tgz", - "integrity": "sha512-ajPXBWS6Oeql3pcQ6aEr4kqglrF30/ykKmm8bZ1z0C8eev8iXISl/gxoSCujtknbpHTMOJZFvS6F+Fk/gRcaDg==", + "version": "26.0.10", + "resolved": "https://registry.npmjs.org/ngx-quill/-/ngx-quill-26.0.10.tgz", + "integrity": "sha512-P+WHt8vjFv9Ze4Lu/VLV/I56nNrvz6NXUQs6FzThaoiVz9foPYb2KifFjowyO946x5EXvVw2CspqC2ukOaLPtg==", "dependencies": { "tslib": "^2.3.0" }, "engines": { - "node": ">=18" + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/core": "^17.0.0", - "quill": "^1.3.7", + "@angular/core": "^18.0.0", + "quill": "^2.0.0", "rxjs": "^7.0.0" } }, @@ -24292,6 +24301,7 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.3" @@ -24307,6 +24317,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, "engines": { "node": ">= 0.4" } @@ -24763,9 +24774,9 @@ } }, "node_modules/parchment": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz", - "integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==" + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz", + "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==" }, "node_modules/parent-module": { "version": "1.0.1", @@ -25604,16 +25615,17 @@ ] }, "node_modules/quill": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz", - "integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz", + "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==", "dependencies": { - "clone": "^2.1.1", - "deep-equal": "^1.0.1", - "eventemitter3": "^2.0.3", - "extend": "^3.0.2", - "parchment": "^1.1.4", - "quill-delta": "^3.6.2" + "eventemitter3": "^5.0.1", + "lodash-es": "^4.17.21", + "parchment": "^3.0.0", + "quill-delta": "^5.1.0" + }, + "engines": { + "npm": ">=8.2.3" } }, "node_modules/quill-cursors": { @@ -25622,35 +25634,22 @@ "integrity": "sha512-oyANfYhqYiRp7OIrX/BurBPb0bvcpqSiS042fGm+T9FNy9UR6MCBzXpCyG7RmnK6OrV2idsdxMbogJ1XSktN5Q==" }, "node_modules/quill-delta": { - "version": "3.6.3", - "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz", - "integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", + "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==", "dependencies": { - "deep-equal": "^1.0.1", - "extend": "^3.0.2", - "fast-diff": "1.1.2" + "fast-diff": "^1.3.0", + "lodash.clonedeep": "^4.5.0", + "lodash.isequal": "^4.5.0" }, "engines": { - "node": ">=0.10" - } - }, - "node_modules/quill-delta/node_modules/fast-diff": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz", - "integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==" - }, - "node_modules/quill/node_modules/clone": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", - "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", - "engines": { - "node": ">=0.8" + "node": ">= 12.0.0" } }, "node_modules/quill/node_modules/eventemitter3": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz", - "integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==" + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" }, "node_modules/randombytes": { "version": "2.1.0", @@ -25872,6 +25871,7 @@ "version": "1.5.3", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", "integrity": "sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.7", @@ -26863,6 +26863,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", @@ -26879,6 +26880,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", diff --git a/src/SIL.XForge.Scripture/ClientApp/package.json b/src/SIL.XForge.Scripture/ClientApp/package.json index 6cdb872d0b..72ef889a8f 100644 --- a/src/SIL.XForge.Scripture/ClientApp/package.json +++ b/src/SIL.XForge.Scripture/ClientApp/package.json @@ -65,13 +65,13 @@ "mingo": "^4.4.1", "ng-circle-progress": "^1.7.1", "ngx-cookie-service": "^18.0.0", - "ngx-quill": "^24.0.0", + "ngx-quill": "^26.0.10", "ngx-transloco-markup": "^4.0.0", "ngx-transloco-markup-router-link": "^4.0.0", "ot-json0": "^1.1.0", "papaparse": "^5.3.2", "process": "^0.11.10", - "quill": "^1.3.7", + "quill": "^2.0.3", "quill-cursors": "^3.1.2", "realtime-server": "file:../../RealtimeServer", "reconnecting-websocket": "^4.4.0", @@ -107,6 +107,7 @@ "@storybook/types": "^8.3.5", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.5.0", + "@testing-library/user-event": "^14.5.2", "@types/auth0-js": "^9.14.6", "@types/events": "^3.0.0", "@types/file-saver": "^2.0.7", @@ -116,7 +117,6 @@ "@types/lodash-es": "^4.17.6", "@types/node": "^22.7.5", "@types/papaparse": "^5.3.2", - "@types/quill": "^1.3.10", "@types/sharedb": "^1.0.24", "@types/tinycolor2": "^1.4.3", "@types/uuid": "8.3.4", diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/app.module.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/app.module.ts index ba53e6d9be..d455eaa4eb 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/app.module.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/app.module.ts @@ -8,6 +8,7 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { ServiceWorkerModule } from '@angular/service-worker'; import { TranslocoModule } from '@ngneat/transloco'; import { CookieService } from 'ngx-cookie-service'; +import { QuillModule } from 'ngx-quill'; import { defaultTranslocoMarkupTranspilers, provideTranslationMarkupTranspiler, @@ -82,7 +83,8 @@ import { UsersModule } from './users/users.module'; SharedModule, AvatarComponent, MatRipple, - GlobalNoticesComponent + GlobalNoticesComponent, + QuillModule.forRoot() ], providers: [ CookieService, diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.spec.ts index a08baff99b..1211941f5d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.spec.ts @@ -16,6 +16,7 @@ import { AngularSplitModule } from 'angular-split'; import { cloneDeep } from 'lodash-es'; import clone from 'lodash-es/clone'; import { CookieService } from 'ngx-cookie-service'; +import { Delta } from 'quill'; import { Operation } from 'realtime-server/lib/esm/common/models/project-rights'; import { User } from 'realtime-server/lib/esm/common/models/user'; import { createTestUser } from 'realtime-server/lib/esm/common/models/user-test-data'; @@ -66,7 +67,7 @@ import { SFProjectProfileDoc } from '../../core/models/sf-project-profile-doc'; import { SFProjectUserConfigDoc } from '../../core/models/sf-project-user-config-doc'; import { SF_TYPE_REGISTRY } from '../../core/models/sf-type-registry'; import { TextAudioDoc } from '../../core/models/text-audio-doc'; -import { Delta, TextDoc } from '../../core/models/text-doc'; +import { TextDoc } from '../../core/models/text-doc'; import { PermissionsService } from '../../core/permissions.service'; import { SFProjectService } from '../../core/sf-project.service'; import { TranslationEngineService } from '../../core/translation-engine.service'; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/question-dialog/question-dialog.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/question-dialog/question-dialog.component.spec.ts index 6f263d7f72..b3674673a6 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/question-dialog/question-dialog.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/question-dialog/question-dialog.component.spec.ts @@ -9,6 +9,7 @@ import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { VerseRef } from '@sillsdev/scripture'; import { CookieService } from 'ngx-cookie-service'; +import { Delta } from 'quill'; import { User } from 'realtime-server/lib/esm/common/models/user'; import { createTestUser } from 'realtime-server/lib/esm/common/models/user-test-data'; import { getQuestionDocId, Question } from 'realtime-server/lib/esm/scriptureforge/models/question'; @@ -41,7 +42,7 @@ import { UserService } from 'xforge-common/user.service'; import { QuestionDoc } from '../../core/models/question-doc'; import { SFProjectProfileDoc } from '../../core/models/sf-project-profile-doc'; import { SF_TYPE_REGISTRY } from '../../core/models/sf-type-registry'; -import { Delta, TextDoc, TextDocId } from '../../core/models/text-doc'; +import { TextDoc, TextDocId } from '../../core/models/text-doc'; import { SFProjectService } from '../../core/sf-project.service'; import { ScriptureChooserDialogComponent } from '../../scripture-chooser-dialog/scripture-chooser-dialog.component'; import { getTextDoc } from '../../shared/test-utils'; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/models/text-doc.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/models/text-doc.ts index 11d52354cf..67ac68645a 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/models/text-doc.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/models/text-doc.ts @@ -1,5 +1,5 @@ import { VerseRef } from '@sillsdev/scripture'; -import Quill, { DeltaOperation, DeltaStatic, RangeStatic } from 'quill'; +import { Range } from 'quill'; import { getTextDocId, TEXT_INDEX_PATHS, @@ -12,8 +12,6 @@ import { RealtimeDocAdapter } from 'xforge-common/realtime-remote-store'; import { RealtimeService } from 'xforge-common/realtime.service'; import { getVerseStrFromSegmentRef } from '../../shared/utils'; -export const Delta: new (ops?: DeltaOperation[] | { ops: DeltaOperation[] }) => DeltaStatic = Quill.import('delta'); - export type TextDocSource = 'Draft' | 'Editor' | 'History' | 'Paratext'; /** @@ -41,7 +39,7 @@ export class TextDocId { * This is the real-time doc for a text doc. Texts contain the textual data for one particular Scripture book * and chapter. */ -export class TextDoc extends RealtimeDoc { +export class TextDoc extends RealtimeDoc { static readonly COLLECTION = TEXTS_COLLECTION; static readonly INDEX_PATHS = TEXT_INDEX_PATHS; @@ -61,12 +59,12 @@ export class TextDoc extends RealtimeDoc { const op = this.data.ops[i]; const nextOp = i < this.data.ops.length - 1 ? this.data.ops[i + 1] : undefined; if (op.attributes != null && op.attributes.segment != null) { - if (op.insert.blank != null) { - const segRef = op.attributes.segment; + if ((op.insert as any).blank != null) { + const segRef: string = op.attributes.segment as string; if ( nextOp == null || nextOp.insert == null || - nextOp.insert.verse == null || + (nextOp.insert as any).verse == null || (segRef.startsWith('verse_') && !segRef.includes('/')) ) { blank++; @@ -84,8 +82,8 @@ export class TextDoc extends RealtimeDoc { let verses: string[] = []; if (this.data != null && this.data.ops != null) { for (const op of this.data.ops) { - if (op.attributes != null && op.attributes.segment != null && op.insert.blank == null) { - const segRef = op.attributes.segment; + if (op.attributes != null && op.attributes.segment != null && (op.insert as any).blank == null) { + const segRef: string = op.attributes.segment as string; if (segRef.startsWith('verse_')) { const verse: string | undefined = getVerseStrFromSegmentRef(segRef); if (verse != null && !verses.includes(verse)) { @@ -138,7 +136,7 @@ export class TextDoc extends RealtimeDoc { continue; } // Locate range of ops that match the verse segments - const opSegmentRef: string = op.attributes?.segment ?? ''; + const opSegmentRef: string = (op.attributes?.segment as string) ?? ''; const segmentVerse: string | undefined = getVerseStrFromSegmentRef(opSegmentRef); if (segmentVerse === verseStr) { text += textBetweenRelatedSegments + op.insert; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.spec.ts index 56fd1c7926..12b0d69f79 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.spec.ts @@ -1,5 +1,5 @@ import { fakeAsync, TestBed, tick } from '@angular/core/testing'; -import { DeltaStatic } from 'quill'; +import { Delta } from 'quill'; import { SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role'; import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; import { TextData } from 'realtime-server/lib/esm/scriptureforge/models/text-data'; @@ -31,7 +31,7 @@ describe('TextDocService', () => { it('should overwrite text doc', fakeAsync(() => { const env = new TestEnvironment(); - const newDelta: DeltaStatic = getCombinedVerseTextDoc(env.textDocId) as DeltaStatic; + const newDelta: Delta = getCombinedVerseTextDoc(env.textDocId) as Delta; env.textDocService.overwrite(env.textDocId, newDelta, 'Editor'); tick(); @@ -41,9 +41,9 @@ describe('TextDocService', () => { it('should emit diff', fakeAsync(() => { const env = new TestEnvironment(); - const origDelta: DeltaStatic = env.getTextDoc(env.textDocId).data as DeltaStatic; - const newDelta: DeltaStatic = getPoetryVerseTextDoc(env.textDocId) as DeltaStatic; - const diff: DeltaStatic = origDelta.diff(newDelta); + const origDelta: Delta = env.getTextDoc(env.textDocId).data as Delta; + const newDelta: Delta = getPoetryVerseTextDoc(env.textDocId) as Delta; + const diff: Delta = origDelta.diff(newDelta); env.textDocService.getLocalSystemChanges$(env.textDocId).subscribe(emittedDiff => { expect(emittedDiff.ops).toEqual(diff.ops); @@ -55,7 +55,7 @@ describe('TextDocService', () => { it('should submit the source', fakeAsync(() => { const env = new TestEnvironment(); - const newDelta: DeltaStatic = getPoetryVerseTextDoc(env.textDocId) as DeltaStatic; + const newDelta: Delta = getPoetryVerseTextDoc(env.textDocId) as Delta; const textDoc = env.getTextDoc(env.textDocId); textDoc.adapter.changes$.subscribe(() => { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.ts index c737be4c22..34d90441d7 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/text-doc.service.ts @@ -1,12 +1,11 @@ import { Injectable } from '@angular/core'; -import { DeltaStatic } from 'quill'; +import { Delta } from 'quill'; import { Operation } from 'realtime-server/lib/esm/common/models/project-rights'; import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; import { SF_PROJECT_RIGHTS, SFProjectDomain } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-rights'; import { TextData } from 'realtime-server/lib/esm/scriptureforge/models/text-data'; import { Chapter, TextInfo } from 'realtime-server/lib/esm/scriptureforge/models/text-info'; import { TextInfoPermission } from 'realtime-server/lib/esm/scriptureforge/models/text-info-permission'; -import { Delta } from 'rich-text'; import { Observable, Subject } from 'rxjs'; import { UserService } from 'xforge-common/user.service'; import { TextDoc, TextDocId, TextDocSource } from './models/text-doc'; @@ -26,18 +25,18 @@ export class TextDocService { /** * Overwrites the specified text doc with the specified delta and then notifies listeners of the changes. * @param {TextDocId} textDocId The id for text doc. - * @param {DeltaStatic} newDelta The ops to overwrite the text doc with. + * @param {Delta} newDelta The ops to overwrite the text doc with. * @param {TextDocSource} source The source of the op. This is sent to the server. */ - async overwrite(textDocId: TextDocId, newDelta: DeltaStatic, source: TextDocSource): Promise { + async overwrite(textDocId: TextDocId, newDelta: Delta, source: TextDocSource): Promise { const textDoc: TextDoc = await this.projectService.getText(textDocId); if (textDoc.data?.ops == null) { throw new Error(`No TextDoc data for ${textDocId}`); } - const origDelta: DeltaStatic = new Delta(textDoc.data.ops); - const diff: DeltaStatic = origDelta.diff(newDelta); + const origDelta: Delta = new Delta(textDoc.data.ops); + const diff: Delta = origDelta.diff(newDelta); // Update text doc directly await textDoc.submit(diff, source); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/shared.module.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/shared.module.ts index cd5b25e5f9..787110485f 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/shared.module.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/shared.module.ts @@ -28,14 +28,7 @@ const componentExports = [ ]; @NgModule({ - imports: [ - CommonModule, - QuillModule.forRoot(), - UICommonModule, - TranslocoModule, - NoticeComponent, - TranslocoMarkupModule - ], + imports: [CommonModule, QuillModule, UICommonModule, TranslocoModule, NoticeComponent, TranslocoMarkupModule], declarations: componentExports, exports: [...componentExports, NoticeComponent] }) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/test-utils.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/test-utils.ts index 0fa781f09a..82a9fbc77b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/test-utils.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/test-utils.ts @@ -1,7 +1,8 @@ +import { Delta } from 'quill'; import { ParatextUserProfile } from 'realtime-server/lib/esm/scriptureforge/models/paratext-user-profile'; import { isParatextRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role'; import { TextData } from 'realtime-server/lib/esm/scriptureforge/models/text-data'; -import { Delta, TextDocId } from '../core/models/text-doc'; +import { TextDocId } from '../core/models/text-doc'; import { RIGHT_TO_LEFT_MARK } from './utils'; export function getTextDoc(id: TextDocId): TextData { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-scripture.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-scripture.ts index 7b57bbeb62..656684d47d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-scripture.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-scripture.ts @@ -1,20 +1,18 @@ -import cloneDeep from 'lodash-es/cloneDeep'; -import Parchment from 'parchment'; -import Quill, { - Clipboard, - DeltaOperation, - DeltaStatic, - History, - HistoryStackType, - QuillOptionsStatic, - StringMap -} from 'quill'; +import { cloneDeep } from 'lodash-es'; +import { Attributor, Formattable, Scope } from 'parchment'; +import Quill, { Delta, Parchment, Range } from 'quill'; import QuillCursors from 'quill-cursors'; +import QuillBlockBlot, { BlockEmbed as QuillBlockEmbedBlot } from 'quill/blots/block'; +import QuillEmbedBlot from 'quill/blots/embed'; +import QuillInlineBlot from 'quill/blots/inline'; +import QuillScrollBlot from 'quill/blots/scroll'; +import QuillTextBlot from 'quill/blots/text'; +import QuillClipboard from 'quill/modules/clipboard'; +import QuillHistory, { StackItem } from 'quill/modules/history'; +import { DeltaOperation, StringMap } from 'rich-text'; import { DragAndDrop } from './drag-and-drop'; import { TextComponent } from './text.component'; -const Delta: new () => DeltaStatic = Quill.import('delta'); - export function getAttributesAtPosition(editor: Quill, editorPosition: number): StringMap { // The format of the insertion point may only contain the block level formatting, // the format classes and other information we get from the character following the insertion point @@ -31,6 +29,19 @@ export function getAttributesAtPosition(editor: Quill, editorPosition: number): return insertionFormat; } +export function getRetainCount(op: DeltaOperation): number | undefined { + if (op?.retain != null) { + if (typeof op.retain === 'number') { + return op.retain; + } + + // The type definition allows it, but we shouldn't encounter an object 'retain' + throw new Error(`Invalid 'retain' operation`); + } + + return undefined; +} + function customAttributeName(key: string): string { return 'data-' + key; } @@ -104,18 +115,17 @@ interface Unmatched { marker: string; } +interface FormattableBlotClass { + new (...args: any[]): Formattable; + blotName: string; +} + +function isAttributor(blot: any): blot is Attributor { + return blot instanceof Attributor; +} + export function registerScripture(): string[] { - const QuillClipboard = Quill.import('modules/clipboard') as typeof Clipboard; - const QuillHistory = Quill.import('modules/history') as typeof History; - const QuillParchment = Quill.import('parchment') as typeof Parchment; - const Inline = Quill.import('blots/inline') as typeof Parchment.Inline; - const Block = Quill.import('blots/block') as typeof Parchment.Block; - const Scroll = Quill.import('blots/scroll') as typeof Parchment.Scroll; - const Embed = Quill.import('blots/embed') as typeof Parchment.Embed; - const BlockEmbed = Quill.import('blots/block/embed') as typeof Parchment.Embed; - const Text = Quill.import('blots/text') as typeof Parchment.Text; - - const formats: any[] = []; + const formats: (FormattableBlotClass | Attributor)[] = []; // zero width space const ZWSP = '\u200b'; @@ -126,13 +136,13 @@ export function registerScripture(): string[] { * This class overrides the "value" method so that it does not normalize text to NFC. This avoids a bug where Quill * does not properly handle NFD data (https://github.com/quilljs/quill/issues/1976). */ - class NotNormalizedText extends Text { + class NotNormalizedText extends QuillTextBlot { static value(domNode: Text): string { return domNode.data; } } - class VerseEmbed extends Embed { + class VerseEmbed extends QuillEmbedBlot { static blotName = 'verse'; static tagName = 'usx-verse'; @@ -163,7 +173,7 @@ export function registerScripture(): string[] { } formats.push(VerseEmbed); - class BlankEmbed extends Embed { + class BlankEmbed extends QuillEmbedBlot { static blotName = 'blank'; static tagName = 'usx-blank'; @@ -192,9 +202,8 @@ export function registerScripture(): string[] { } } formats.push(BlankEmbed); - formats.push('initial'); - class EmptyEmbed extends Embed { + class EmptyEmbed extends QuillEmbedBlot { static blotName = 'empty'; static tagName = 'usx-empty'; @@ -217,12 +226,12 @@ export function registerScripture(): string[] { formats.push(EmptyEmbed); /** Span of characters or elements, that can have formatting. */ - class CharInline extends Inline { + class CharInline extends QuillInlineBlot { static blotName = 'char'; static tagName = 'usx-char'; - static create(value: UsxStyle | UsxStyle[]): Node { - const node = super.create(value) as HTMLElement; + static create(value: UsxStyle | UsxStyle[]): HTMLElement { + const node = super.create(value); if (value == null) { return node; } @@ -268,12 +277,12 @@ export function registerScripture(): string[] { } formats.push(CharInline); - class RefInline extends Inline { + class RefInline extends QuillInlineBlot { static blotName = 'ref'; static tagName = 'usx-ref'; - static create(value: Ref): Node { - const node = super.create(value) as HTMLElement; + static create(value: Ref): HTMLElement { + const node = super.create(value); node.setAttribute(customAttributeName('loc'), value.loc); setUsxValue(node, value); return node; @@ -300,7 +309,7 @@ export function registerScripture(): string[] { } formats.push(RefInline); - class NoteEmbed extends Embed { + class NoteEmbed extends QuillEmbedBlot { static blotName = 'note'; static tagName = 'usx-note'; @@ -330,7 +339,7 @@ export function registerScripture(): string[] { } formats.push(NoteEmbed); - class NoteThreadEmbed extends Embed { + class NoteThreadEmbed extends QuillEmbedBlot { static blotName = 'note-thread-embed'; static tagName = 'display-note'; @@ -369,7 +378,7 @@ export function registerScripture(): string[] { } formats.push(NoteThreadEmbed); - class OptBreakEmbed extends Embed { + class OptBreakEmbed extends QuillEmbedBlot { static blotName = 'optbreak'; static tagName = 'usx-optbreak'; @@ -386,7 +395,7 @@ export function registerScripture(): string[] { } formats.push(OptBreakEmbed); - class FigureEmbed extends Embed { + class FigureEmbed extends QuillEmbedBlot { static blotName = 'figure'; static tagName = 'usx-figure'; @@ -429,7 +438,7 @@ export function registerScripture(): string[] { } formats.push(FigureEmbed); - class UnmatchedEmbed extends Embed { + class UnmatchedEmbed extends QuillEmbedBlot { static blotName = 'unmatched'; static tagName = 'usx-unmatched'; @@ -446,12 +455,12 @@ export function registerScripture(): string[] { } formats.push(UnmatchedEmbed); - class ParaBlock extends Block { + class ParaBlock extends QuillBlockBlot { static blotName = 'para'; static tagName = 'usx-para'; - static create(value: Para): Node { - const node = super.create(value) as HTMLElement; + static create(value: Para): HTMLElement { + const node = super.create(value); if (value != null && value.style != null) { node.setAttribute(customAttributeName('style'), value.style); setUsxValue(node, value); @@ -480,7 +489,7 @@ export function registerScripture(): string[] { } formats.push(ParaBlock); - class ParaInline extends Inline { + class ParaInline extends QuillInlineBlot { static blotName = 'para-contents'; static tagName = 'usx-para-contents'; @@ -509,7 +518,7 @@ export function registerScripture(): string[] { if (child != null) { const after = child.split(offset); const node = VerseEmbed.create(def); - const blot = new VerseEmbed(node); + const blot = new VerseEmbed(this.scroll as QuillScrollBlot, node); this.insertBefore(blot, after); return; } @@ -519,12 +528,12 @@ export function registerScripture(): string[] { } formats.push(ParaInline); - class SegmentInline extends Inline { + class SegmentInline extends QuillInlineBlot { static blotName = 'segment'; static tagName = 'usx-segment'; - static create(value: string): Node { - const node = super.create(value) as HTMLElement; + static create(value: string): HTMLElement { + const node = super.create(value); node.setAttribute(customAttributeName('segment'), value); return node; } @@ -547,7 +556,7 @@ export function registerScripture(): string[] { } formats.push(SegmentInline); - class TextAnchorInline extends Inline { + class TextAnchorInline extends QuillInlineBlot { static blotName = 'text-anchor'; static tagName = 'display-text-anchor'; } @@ -555,12 +564,12 @@ export function registerScripture(): string[] { // Lower index means deeper in the DOM tree i.e. text-anchor will be nested inside of char. If char doesn't exist // then it will nest inside the next available element higher up the DOM - (Inline as any).order.push('text-anchor'); - (Inline as any).order.push('char'); - (Inline as any).order.push('segment'); - (Inline as any).order.push('para-contents'); + QuillInlineBlot.order.push('text-anchor'); + QuillInlineBlot.order.push('char'); + QuillInlineBlot.order.push('segment'); + QuillInlineBlot.order.push('para-contents'); - class ChapterEmbed extends BlockEmbed { + class ChapterEmbed extends QuillBlockEmbedBlot { static blotName = 'chapter'; static tagName = 'usx-chapter'; @@ -579,10 +588,10 @@ export function registerScripture(): string[] { } } formats.push(ChapterEmbed); - Scroll.allowedChildren.push(ParaBlock); - Scroll.allowedChildren.push(ChapterEmbed); + QuillScrollBlot.allowedChildren.push(ParaBlock); + QuillScrollBlot.allowedChildren.push(ChapterEmbed); - class ClassAttributor extends QuillParchment.Attributor.Class { + class ClassAttributor extends Parchment.ClassAttributor { add(node: HTMLElement, value: any): boolean { if (value === true) { this.remove(node); @@ -630,22 +639,14 @@ export function registerScripture(): string[] { }); formats.push(CheckingQuestionSegmentClass); - const CheckingQuestionCountAttribute = new QuillParchment.Attributor.Attribute( - 'question-count', - 'data-question-count', - { - scope: Parchment.Scope.INLINE - } - ); + const CheckingQuestionCountAttribute = new Parchment.Attributor('question-count', 'data-question-count', { + scope: Parchment.Scope.INLINE + }); formats.push(CheckingQuestionCountAttribute); - const ParaStyleDescriptionAttribute = new QuillParchment.Attributor.Attribute( - 'style-description', - 'data-style-description', - { - scope: Parchment.Scope.INLINE - } - ); + const ParaStyleDescriptionAttribute = new Parchment.Attributor('style-description', 'data-style-description', { + scope: Parchment.Scope.INLINE + }); formats.push(ParaStyleDescriptionAttribute); const NoteThreadSegmentClass = new ClassAttributor('note-thread-segment', 'note-thread-segment', { @@ -680,12 +681,12 @@ export function registerScripture(): string[] { class DisableHtmlClipboard extends QuillClipboard { private _textComponent: TextComponent; - constructor(quill: Quill, options: QuillOptionsStatic) { + constructor(quill: Quill, options: StringMap) { super(quill, options); - this._textComponent = (options as any).textComponent; + this._textComponent = options.textComponent; } - onPaste(e: ClipboardEvent): void { + onCapturePaste(e: ClipboardEvent): void { if (e.defaultPrevented || !this.quill.isEnabled() || e.clipboardData == null) { return; } @@ -694,71 +695,91 @@ export function registerScripture(): string[] { // happen anyway even if we stop processing here. e.preventDefault(); - const range = this.quill.getSelection(); + const range: Range = this.quill.getSelection(true); if (range == null) { return; } + if (!this._textComponent.isValidSelectionForCurrentSegment(range)) { return; } + let delta = new Delta().retain(range.index); - const scrollTop = this.quill.scrollingContainer.scrollTop; - this.container.focus(); - this.quill.selection.update('silent'); - - let text = e.clipboardData.getData('text/plain'); - // do not allow pasting new lines - text = text.replace(/(?:\r?\n)+/, ' '); - // do not allow pasting backslashes - text = text.replace(/\\/g, ''); - setTimeout(() => { - this.container.innerHTML = text; - const pasteDelta: DeltaStatic = this.convert(); - if (pasteDelta.ops != null) { - for (const op of pasteDelta.ops) { - // add the attributes to the paste delta which should just be 1 insert op - op.attributes = getAttributesAtPosition(this.quill, range.index); - } - } - delta = delta.concat(pasteDelta).delete(range.length); - this.quill.updateContents(delta, 'user'); - // range.length contributes to delta.length() - this.quill.setSelection(delta.length() - range.length, 'silent'); - this.quill.scrollingContainer.scrollTop = scrollTop; - this.quill.focus(); - }, 1); + + const text = e.clipboardData.getData('text/plain'); + const cleanedText = text + .replace(/(?:\r?\n)+/, ' ') // Replace new lines with spaces + .replace(/\\/g, ''); // Remove backslashes + + const pasteDelta = this.convert({ text: cleanedText }); + + // add the attributes to the paste delta which should just be 1 insert op + for (const op of pasteDelta.ops ?? []) { + op.attributes = getAttributesAtPosition(this.quill, range.index); + } + + delta = delta.concat(pasteDelta).delete(range.length); + this.quill.updateContents(delta, 'user'); + this.quill.setSelection(delta.length() - range.length, 'silent'); } } + type HistoryStackType = 'undo' | 'redo'; + class FixSelectionHistory extends QuillHistory { /** - * Performs undo/redo. Override this method, so that we can fix the selection logic. This method was copied from + * Performs undo/redo. Override this method so that we can fix the selection logic. This method was copied from * the Quill history module. * - * @param {string} source The source stack type. - * @param {string} dest The destination stack type. + * @param {HistoryStackType} source The source stack type. + * @param {HistoryStackType} dest The destination stack type. */ change(source: HistoryStackType, dest: HistoryStackType): void { - const delta = this.stack[source].pop(); - if (delta == null) { + if (this.stack[source].length === 0) { + return; + } + const stackItem: StackItem = this.stack[source].pop(); + if (stackItem == null) { return; } - this.stack[dest].push(delta); + const base = this.quill.getContents(); + const inverseDelta = stackItem.delta.invert(base); + this.stack[dest].push({ + delta: inverseDelta, + range: transformRange(stackItem.range, inverseDelta) + }); this.lastRecorded = 0; this.ignoreChange = true; // during undo/redo, segments can be incorrectly highlighted, so explicitly remove incorrect highlighting - this.quill.updateContents(removeObsoleteSegmentAttrs(delta[source]), 'user'); + this.quill.updateContents(removeObsoleteSegmentAttrs(stackItem.delta), Quill.sources.USER); this.ignoreChange = false; - const index = getLastChangeIndex(delta[source]); + const index = getLastChangeIndex(this.quill.scroll, stackItem.delta); this.quill.setSelection(index); } } + /** + * Transforms a range based on a delta. This function was copied from the Quill history module. + */ + function transformRange(range: Range | null, delta: Delta): Range { + if (!range) { + return range; + } + + const start: number = delta.transformPosition(range.index); + const end: number = delta.transformPosition(range.index + range.length); + + return { + index: start, + length: end - start + }; + } + /** * Updates delta to remove segment highlights from segments that are not explicitly highlighted * and strips away formatting from embeds, excluding blanks. */ - function removeObsoleteSegmentAttrs(delta: DeltaStatic): DeltaStatic { + function removeObsoleteSegmentAttrs(delta: Delta): Delta { const updatedDelta = new Delta(); if (delta.ops != null) { for (const op of delta.ops) { @@ -790,11 +811,8 @@ export function registerScripture(): string[] { /** * Checks if the delta ends with a newline insert. This function was copied from the Quill history module. */ - function endsWithNewlineChange(delta: DeltaStatic): boolean { - if (delta.ops == null) { - return false; - } - const lastOp = delta.ops[delta.ops.length - 1]; + function endsWithNewlineChange(scroll: QuillScrollBlot, delta: Delta): boolean { + const lastOp = delta.ops?.[delta.ops.length - 1]; if (lastOp == null) { return false; } @@ -802,8 +820,8 @@ export function registerScripture(): string[] { return typeof lastOp.insert === 'string' && lastOp.insert.endsWith('\n'); } if (lastOp.attributes != null) { - return Object.keys(lastOp.attributes).some(function (attr) { - return Parchment.query(attr, Parchment.Scope.BLOCK) != null; + return Object.keys(lastOp.attributes).some(attr => { + return scroll.query(attr, Scope.BLOCK) != null; }); } return false; @@ -813,10 +831,11 @@ export function registerScripture(): string[] { * Finds the index where the last insert/delete occurs in the delta. This function has been modified from the * original in the Quill history module. * - * @param {DeltaStatic} delta The undo/redo delta. + * @param {QuillScrollBlot} scroll The Quill scroll. + * @param {Delta} delta The undo/redo delta. * @returns {number} The index where the last insert/delete occurs. */ - function getLastChangeIndex(delta: DeltaStatic): number { + function getLastChangeIndex(scroll: QuillScrollBlot, delta: Delta): number { if (delta.ops == null) { return 0; } @@ -832,11 +851,12 @@ export function registerScripture(): string[] { curIndex++; } } else if (op.retain != null) { - curIndex += op.retain; + const retainCount: number = getRetainCount(op); + curIndex += retainCount; changeIndex = curIndex; } } - if (endsWithNewlineChange(delta)) { + if (endsWithNewlineChange(scroll, delta)) { changeIndex -= 1; } return changeIndex; @@ -844,17 +864,16 @@ export function registerScripture(): string[] { const formatNames: string[] = []; for (const format of formats) { - if (typeof format === 'string') { - formatNames.push(format); - } else if (format.blotName != null) { - Quill.register(`blots/${format.blotName}`, format); - formatNames.push(format.blotName); - } else { + if (isAttributor(format)) { Quill.register(`formats/${format.attrName}`, format); formatNames.push(format.attrName); + } else { + Quill.register(`blots/${format.blotName}`, format); + formatNames.push(format.blotName); } } - Quill.register('blots/scroll', Scroll, true); + + Quill.register('blots/scroll', QuillScrollBlot, true); Quill.register('blots/text', NotNormalizedText, true); Quill.register('modules/clipboard', DisableHtmlClipboard, true); Quill.register('modules/cursors', QuillCursors); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/segment.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/segment.ts index 3804f3ce12..2589d49ccd 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/segment.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/segment.ts @@ -1,11 +1,11 @@ import * as crc from 'crc-32'; -import { RangeStatic } from 'quill'; +import { Range } from 'quill'; export class Segment { initialChecksum?: number; private _text: string = ''; - private _range: RangeStatic = { index: 0, length: 0 }; + private _range: Range = { index: 0, length: 0 }; private _checksum?: number; private initialTextLen: number = -1; @@ -19,7 +19,7 @@ export class Segment { return this._text; } - get range(): RangeStatic { + get range(): Range { return this._range; } @@ -43,7 +43,7 @@ export class Segment { this.initialChecksum = this.checksum; } - update(text: string, range: RangeStatic): void { + update(text: string, range: Range): void { this._text = text; this._range = range; this._checksum = undefined; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text-view-model.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text-view-model.ts index 01a34c75f8..309260df2e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text-view-model.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text-view-model.ts @@ -1,11 +1,13 @@ import { Injectable, OnDestroy } from '@angular/core'; import { VerseRef } from '@sillsdev/scripture'; import cloneDeep from 'lodash-es/cloneDeep'; -import Quill, { DeltaOperation, DeltaStatic, RangeStatic, Sources, StringMap } from 'quill'; +import Quill, { Delta, EmitterSource, Range } from 'quill'; +import { DeltaOperation, StringMap } from 'rich-text'; import { Subscription } from 'rxjs'; -import { Delta, TextDoc, TextDocId } from '../../core/models/text-doc'; +import { isString } from '../../../type-utils'; +import { TextDoc, TextDocId } from '../../core/models/text-doc'; import { getVerseStrFromSegmentRef, isBadDelta } from '../utils'; -import { getAttributesAtPosition } from './quill-scripture'; +import { getAttributesAtPosition, getRetainCount } from './quill-scripture'; import { USFM_STYLE_DESCRIPTIONS } from './usfm-style-descriptions'; /** See also DeltaUsxMapper.cs ParagraphPoetryListStyles. */ @@ -124,7 +126,7 @@ class SegmentInfo { export class TextViewModel implements OnDestroy { editor?: Quill; - private readonly _segments: Map = new Map(); + private readonly _segments: Map = new Map(); private changesSub?: Subscription; private onCreateSub?: Subscription; private textDoc?: TextDoc; @@ -135,11 +137,11 @@ export class TextViewModel implements OnDestroy { */ private _embeddedElements: Map = new Map(); - get segments(): IterableIterator<[string, RangeStatic]> { + get segments(): IterableIterator<[string, Range]> { return this._segments.entries(); } - get segmentsSnapshot(): IterableIterator<[string, RangeStatic]> { + get segmentsSnapshot(): IterableIterator<[string, Range]> { return cloneDeep(this._segments).entries(); } @@ -188,19 +190,19 @@ export class TextViewModel implements OnDestroy { this.textDocId = textDocId; this.textDoc = textDoc; - editor.setContents(this.textDoc.data as DeltaStatic); + editor.setContents(this.textDoc.data as Delta); editor.history.clear(); if (subscribeToUpdates) { this.changesSub = this.textDoc.remoteChanges$.subscribe(ops => { - const deltaWithEmbeds: DeltaStatic = this.addEmbeddedElementsToDelta(ops as DeltaStatic); + const deltaWithEmbeds: Delta = this.addEmbeddedElementsToDelta(ops as Delta); editor.updateContents(deltaWithEmbeds, 'api'); }); } this.onCreateSub = this.textDoc.create$.subscribe(() => { if (textDoc.data != null) { - editor.setContents(textDoc.data as DeltaStatic); + editor.setContents(textDoc.data as Delta); } editor.history.clear(); }); @@ -221,11 +223,11 @@ export class TextViewModel implements OnDestroy { * Updates the view model (textDoc), segment ranges, and slightly the Quill contents, such as in response to text * changing in the quill editor. * - * @param {DeltaStatic} delta The view model delta. - * @param {Sources} source The source of the change. + * @param {Delta} delta The view model delta. + * @param {EmitterSource} source The source of the change. * @param {boolean} isOnline Whether the user is online. */ - update(delta: DeltaStatic, source: Sources, isOnline: boolean): void { + update(delta: Delta, source: EmitterSource, isOnline: boolean): void { const editor = this.checkEditor(); if (this.textDoc == null) { return; @@ -250,7 +252,7 @@ export class TextViewModel implements OnDestroy { editor.updateContents(updateDelta, source); } - const removeDuplicateDelta: DeltaStatic = this.fixDeltaForDuplicateEmbeds(); + const removeDuplicateDelta: Delta = this.fixDeltaForDuplicateEmbeds(); if (removeDuplicateDelta.ops && removeDuplicateDelta.ops.length > 0) { editor.updateContents(removeDuplicateDelta, 'api'); } @@ -268,7 +270,7 @@ export class TextViewModel implements OnDestroy { const len = typeof op.insert === 'string' ? op.insert.length : 1; const attrs = op.attributes; let newAttrs: StringMap | undefined; - if (attrs != null && attrs['segment'] != null) { + if (isString(attrs?.['segment'])) { if (refs.has(attrs['segment'])) { // highlight segment newAttrs = { 'highlight-segment': true }; @@ -316,11 +318,13 @@ export class TextViewModel implements OnDestroy { } const highlightedSegmentIndex = delta.ops.findIndex(op => op.attributes?.['highlight-segment'] === true); - const styleOpIndexes = delta.ops.map((op, i) => (op.attributes?.para?.style ? i : -1)).filter(i => i !== -1); + const styleOpIndexes = delta.ops + .map((op, i) => ((op.attributes?.para as any)?.style ? i : -1)) + .filter(i => i !== -1); // This may be -1 if there is no style specified const indexOfParagraphStyle = Math.min(...styleOpIndexes.filter(i => i > highlightedSegmentIndex)); - const style = delta.ops[indexOfParagraphStyle]?.attributes?.para?.style; + const style = (delta.ops[indexOfParagraphStyle]?.attributes?.para as any)?.style; const description = USFM_STYLE_DESCRIPTIONS[style]; if (typeof description !== 'string' || style === 'p') { return; @@ -380,7 +384,7 @@ export class TextViewModel implements OnDestroy { return segmentsInVerseRef; } - getSegmentRange(ref: string): RangeStatic | undefined { + getSegmentRange(ref: string): Range | undefined { return this._segments.get(ref); } @@ -390,9 +394,9 @@ export class TextViewModel implements OnDestroy { return range == null ? '' : editor.getText(range.index, range.length); } - getSegmentContents(ref: string): DeltaStatic | undefined { + getSegmentContents(ref: string): Delta | undefined { const editor: Quill = this.checkEditor(); - const range: RangeStatic | undefined = this.getSegmentRange(ref); + const range: Range | undefined = this.getSegmentRange(ref); return range == null ? undefined : editor.getContents(range.index, range.length); } @@ -400,7 +404,7 @@ export class TextViewModel implements OnDestroy { * Returns the segment reference with the most overlap of given range. * Preference is given to the specified segment if it is wholly contained within the range. */ - getSegmentRef(range: RangeStatic, preferRef?: string): string | undefined { + getSegmentRef(range: Range, preferRef?: string): string | undefined { let segmentRef: string | undefined; let maxOverlap = -1; @@ -491,7 +495,7 @@ export class TextViewModel implements OnDestroy { return leadingEmbedCount; } - private viewToData(delta: DeltaStatic): DeltaStatic { + private viewToData(delta: Delta): Delta { let modelDelta = new Delta(); if (delta.ops != null) { for (const op of delta.ops) { @@ -527,7 +531,7 @@ export class TextViewModel implements OnDestroy { * Re-generate segment boundaries from quill editor ops. Return ops to clean up where and whether blanks are * represented. */ - private updateSegments(editor: Quill, isOnline: boolean): DeltaStatic { + private updateSegments(editor: Quill, isOnline: boolean): Delta { const convertDelta = new Delta(); let fixDelta = new Delta(); let fixOffset = 0; @@ -545,11 +549,11 @@ export class TextViewModel implements OnDestroy { for (const op of delta.ops) { const attrs: StringMap = {}; const len = typeof op.insert === 'string' ? op.insert.length : 1; - if (op.insert === '\n' || (op.attributes != null && op.attributes.para != null)) { - const style = op.attributes == null || op.attributes.para == null ? null : (op.attributes.para.style as string); + if (op.insert === '\n' || op.attributes?.para != null) { + const style: string = op.attributes?.para == null ? null : ((op.attributes.para as any).style as string); if (style == null || canParaContainVerseText(style)) { // paragraph - for (const _ch of op.insert) { + for (const _ch of op.insert as any) { if (curSegment != null) { paraSegments.push(curSegment); curIndex += curSegment.length; @@ -603,12 +607,12 @@ export class TextViewModel implements OnDestroy { curIndex += curSegment.length + len; curSegment = undefined; } - } else if (op.insert.chapter != null) { + } else if ((op.insert as any).chapter != null) { // chapter - chapter = op.insert.chapter.number; + chapter = (op.insert as any).chapter.number; curIndex += len; curSegment = undefined; - } else if (op.insert.verse != null) { + } else if ((op.insert as any).verse != null) { // verse if (curSegment != null) { curSegment.isVerseNext = true; @@ -619,28 +623,28 @@ export class TextViewModel implements OnDestroy { } setAttribute(op, attrs, 'para-contents', true); curIndex += len; - curSegment = new SegmentInfo('verse_' + chapter + '_' + op.insert.verse.number, curIndex); + curSegment = new SegmentInfo('verse_' + chapter + '_' + (op.insert as any).verse.number, curIndex); } else { // segment setAttribute(op, attrs, 'para-contents', true); if (curSegment == null) { curSegment = new SegmentInfo('', curIndex); } - const opSegRef = op.attributes != null && op.attributes['segment'] != null ? op.attributes['segment'] : ''; + const opSegRef: string = op.attributes?.['segment'] != null ? (op.attributes['segment'] as string) : ''; if (curSegment.origRef == null) { curSegment.origRef = opSegRef; } else if (curSegment.origRef !== opSegRef) { curSegment.origRef = ''; } curSegment.length += len; - if (op.insert != null && op.insert.blank != null) { + if ((op.insert as any)?.blank != null) { curSegment.containsBlank = true; if (op.attributes != null && op.attributes['initial'] === true) { curSegment.hasInitialFormat = true; } } else if (op.insert['note-thread-embed'] != null) { // record the presence of an embedded note in the segment - const id = op.attributes != null && op.attributes['threadid']; + const id: string | undefined = op.attributes?.['threadid'] as string | undefined; let embedPosition: EmbedPosition | undefined = this._embeddedElements.get(id); const position: number = curIndex + curSegment.length - 1; if (embedPosition == null) { @@ -668,10 +672,10 @@ export class TextViewModel implements OnDestroy { private fixSegment( editor: Quill, segment: SegmentInfo, - fixDelta: DeltaStatic, + fixDelta: Delta, fixOffset: number, isOnline: boolean - ): [DeltaStatic, number] { + ): [Delta, number] { // inserting blank embeds onto text docs while offline creates a scenario where quill misinterprets // the diff delta and can cause merge issues when returning online and duplicating verse segments if (segment.length - segment.notesCount === 0 && isOnline) { @@ -725,7 +729,7 @@ export class TextViewModel implements OnDestroy { return result; } - private fixDeltaForDuplicateEmbeds(): DeltaStatic { + private fixDeltaForDuplicateEmbeds(): Delta { let delta = new Delta(); const duplicatePositions: EmbedPosition[] = Array.from(this._embeddedElements.values()).filter( ep => ep.duplicatePosition != null @@ -750,7 +754,7 @@ export class TextViewModel implements OnDestroy { * Strip off the embedded elements displayed in quill from the delta. This can be used to convert a delta from * user edits to apply to a text doc. */ - private removeEmbeddedElementsFromDelta(modelDelta: DeltaStatic): DeltaStatic { + private removeEmbeddedElementsFromDelta(modelDelta: Delta): Delta { if (modelDelta.ops == null || modelDelta.ops.length < 1) { return new Delta(); } @@ -759,10 +763,12 @@ export class TextViewModel implements OnDestroy { for (const op of modelDelta.ops) { let cloneOp: DeltaOperation | undefined = cloneDeep(op); if (cloneOp.retain != null) { - const embedsInRange: number = this.getEmbedsInEditorRange(curIndex, cloneOp.retain); - curIndex += cloneOp.retain; + const retainCount: number = getRetainCount(cloneOp); + const embedsInRange: number = this.getEmbedsInEditorRange(curIndex, retainCount); + curIndex += retainCount; + // remove from the retain op the number of embedded elements contained in its content - cloneOp.retain -= embedsInRange; + (cloneOp.retain as number) -= embedsInRange; } else if (cloneOp.delete != null) { const embedsInRange: number = this.getEmbedsInEditorRange(curIndex, cloneOp.delete); curIndex += cloneOp.delete; @@ -787,7 +793,7 @@ export class TextViewModel implements OnDestroy { * Add in the embedded elements displayed in quill to the delta. This can be used to convert a delta from a remote * edit to apply to the current editor content. */ - private addEmbeddedElementsToDelta(modelDelta: DeltaStatic): DeltaStatic { + private addEmbeddedElementsToDelta(modelDelta: Delta): Delta { if (modelDelta.ops == null || modelDelta.ops.length < 1) { return new Delta(); } @@ -800,10 +806,11 @@ export class TextViewModel implements OnDestroy { let cloneOp: DeltaOperation = cloneDeep(op); editorStartPos = curIndex + embedsUpToIndex; if (cloneOp.retain != null) { + const retainCount: number = getRetainCount(cloneOp); // editorStartPos must be the current index plus the number of embeds previous - const editorRange: EditorRange = this.getEditorContentRange(editorStartPos, cloneOp.retain); + const editorRange: EditorRange = this.getEditorContentRange(editorStartPos, retainCount); embedsUpToIndex += editorRange.embedsWithinRange; - curIndex += cloneOp.retain; + curIndex += retainCount; let embedsToRetain: number = editorRange.embedsWithinRange; // remove any embeds subsequent to the previous insert so they can be redrawn in the right place if (editorRange.leadingEmbedCount > 0 && previousOp === 'insert') { @@ -811,7 +818,7 @@ export class TextViewModel implements OnDestroy { embedsToRetain -= editorRange.leadingEmbedCount; } // add to the retain op the number of embedded elements contained in its content - cloneOp.retain += embedsToRetain; + (cloneOp.retain as number) += embedsToRetain; previousOp = 'retain'; } else if (cloneOp.delete != null) { const editorRange: EditorRange = this.getEditorContentRange(editorStartPos, cloneOp.delete); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.html index e14671416e..4045a9dc87 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.html @@ -5,7 +5,6 @@ spellcheck="false" [placeholder]="placeholder" [readOnly]="readOnlyEnabled" - [strict]="false" [formats]="allowedFormats" [modules]="modules" (onEditorCreated)="onEditorCreated($event)" diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.spec.ts index 574b67c33e..c7c517eff2 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.spec.ts @@ -3,7 +3,7 @@ import { Component, ViewChild } from '@angular/core'; import { ComponentFixture, TestBed, fakeAsync, flush, tick } from '@angular/core/testing'; import { TranslocoService } from '@ngneat/transloco'; import { VerseRef } from '@sillsdev/scripture'; -import Quill, { DeltaStatic, RangeStatic, Sources } from 'quill'; +import Quill, { Delta, EmitterSource, Range as QuillRange } from 'quill'; import QuillCursors from 'quill-cursors'; import { User } from 'realtime-server/lib/esm/common/models/user'; import { createTestUser } from 'realtime-server/lib/esm/common/models/user-test-data'; @@ -30,7 +30,7 @@ import { UserService } from 'xforge-common/user.service'; import { isGecko } from 'xforge-common/utils'; import { SFProjectProfileDoc } from '../../core/models/sf-project-profile-doc'; import { SF_TYPE_REGISTRY } from '../../core/models/sf-type-registry'; -import { Delta, TextDoc, TextDocId } from '../../core/models/text-doc'; +import { TextDoc, TextDocId } from '../../core/models/text-doc'; import { SFProjectService } from '../../core/sf-project.service'; import { SharedModule } from '../shared.module'; import { getCombinedVerseTextDoc, getEmptyChapterDoc, getPoetryVerseTextDoc, getTextDoc } from '../test-utils'; @@ -414,7 +414,7 @@ describe('TextComponent', () => { env.id = new TextDocId('project01', 43, 1); env.waitForEditor(); - const range: RangeStatic = env.component.getSegmentRange('s_3')!; + const range: QuillRange = env.component.getSegmentRange('s_3')!; env.component.editor!.setSelection(range.index + 1, 'user'); tick(); env.fixture.detectChanges(); @@ -422,7 +422,7 @@ describe('TextComponent', () => { // SUT env.triggerUndo(); - const rangePostUndo: RangeStatic | undefined = env.component.getSegmentRange('s_3'); + const rangePostUndo: QuillRange | undefined = env.component.getSegmentRange('s_3'); expect(rangePostUndo).toBeTruthy(); TestEnvironment.waitForPresenceTimer(); @@ -434,20 +434,20 @@ describe('TextComponent', () => { env.id = new TextDocId('project01', 43, 1); env.waitForEditor(); - const range: RangeStatic = env.component.getSegmentRange('verse_1_1')!; + const range: QuillRange = env.component.getSegmentRange('verse_1_1')!; env.component.toggleVerseSelection(new VerseRef('JHN 1:1')); env.component.editor!.setSelection(range.index + 1, 'user'); tick(); env.fixture.detectChanges(); - let contents: DeltaStatic = env.component.getSegmentContents('verse_1_1')!; + let contents: Delta = env.component.getSegmentContents('verse_1_1')!; expect(contents.ops![0].attributes!['commenter-selection']).toBe(true); - expect(contents.ops![0].insert.blank).toBe(true); + expect((contents.ops![0].insert as any).blank).toBe(true); const formats = getAttributesAtPosition(env.component.editor!, range.index); // use apply delta to control the formatting env.applyDelta(new Delta().retain(range.index).insert('text', formats).delete(1), 'user'); contents = env.component.getSegmentContents('verse_1_1')!; expect(contents.ops![0].attributes!['commenter-selection']).toBe(true); - const verse2Range: RangeStatic = env.component.getSegmentRange('verse_1_2')!; + const verse2Range: QuillRange = env.component.getSegmentRange('verse_1_2')!; env.component.editor!.setSelection(verse2Range.index + 1, 'user'); env.component.toggleVerseSelection(new VerseRef('JHN 1:2')); env.component.toggleVerseSelection(new VerseRef('JHN 1:1')); @@ -462,7 +462,7 @@ describe('TextComponent', () => { env.component.toggleVerseSelection(new VerseRef('JHN 1:1')); contents = env.component.getSegmentContents('verse_1_1')!; expect(contents.ops![0].attributes!['commenter-selection']).toBe(true); - expect(contents.ops![0].insert.blank).toBe(true); + expect((contents.ops![0].insert as any).blank).toBe(true); TestEnvironment.waitForPresenceTimer(); })); @@ -476,14 +476,14 @@ describe('TextComponent', () => { tick(); env.fixture.detectChanges(); - const range: RangeStatic = env.component.getSegmentRange('verse_1_1')!; + const range: QuillRange = env.component.getSegmentRange('verse_1_1')!; env.component.editor!.setSelection(range.index, 0, 'user'); tick(); env.fixture.detectChanges(); let segmentElement: HTMLElement = env.getSegment('verse_1_1')!; expect(segmentElement.classList).toContain('note-thread-segment'); const pasteText = 'paste text'; - let contents: DeltaStatic = env.component.getSegmentContents('verse_1_1')!; + let contents: Delta = env.component.getSegmentContents('verse_1_1')!; expect(contents.ops![0].insert).toEqual('target: '); const dataTransfer = new DataTransfer(); dataTransfer.setData('text/plain', pasteText); @@ -513,7 +513,7 @@ describe('TextComponent', () => { env.waitForEditor(); for (const ref of testSegmentRefs) { - const range: RangeStatic = env.component.getSegmentRange(ref)!; + const range: QuillRange = env.component.getSegmentRange(ref)!; // Set segment as current segment env.component.editor?.setSelection(range.index, 0, 'user'); @@ -552,7 +552,7 @@ describe('TextComponent', () => { env.fixture.detectChanges(); env.id = new TextDocId('project01', 40, 1); env.waitForEditor(); - const cursors: QuillCursors = env.component.editor!.getModule('cursors'); + const cursors: QuillCursors = env.component.editor!.getModule('cursors') as QuillCursors; const cursorRemoveSpy = spyOn(cursors, 'removeCursor').and.callThrough(); const onSelectionChangedSpy = spyOn(env.component, 'onSelectionChanged').and.callThrough(); const localPresenceSubmitSpy = spyOn(env.localPresenceDoc, 'submit').and.callThrough(); @@ -560,7 +560,7 @@ describe('TextComponent', () => { env.component.editor?.setSelection(1, 1, 'user'); // ShareDB will trigger a presence "submit" event on the doc, so we need to simulate that event - const range: RangeStatic = env.component.getSegmentRange('verse_1_1')!; + const range: QuillRange = env.component.getSegmentRange('verse_1_1')!; (env.component as any).onPresenceDocReceive('presenceId', range); tick(); @@ -584,7 +584,7 @@ describe('TextComponent', () => { expect(localPresenceSubmitSpy).toHaveBeenCalledTimes(1); verify(mockedUserService.getCurrentUser()).once(); - env.component.onSelectionChanged(null as unknown as RangeStatic); + env.component.onSelectionChanged(null as unknown as QuillRange); tick(); expect(onSelectionChangedSpy).toHaveBeenCalledTimes(2); @@ -672,7 +672,7 @@ describe('TextComponent', () => { env.waitForEditor(); const presenceChannelSubmit = spyOn(env.localPresenceChannel, 'submit'); - const range: RangeStatic = env.component.getSegmentRange('verse_1_1')!; + const range: QuillRange = env.component.getSegmentRange('verse_1_1')!; env.component.editor!.setSelection(range.index + 1, 'user'); tick(); env.fixture.detectChanges(); @@ -696,7 +696,7 @@ describe('TextComponent', () => { env.waitForEditor(); const presenceDocSubmit = spyOn(env.localPresenceDoc, 'submit'); - const range: RangeStatic = env.component.getSegmentRange('verse_1_1')!; + const range: QuillRange = env.component.getSegmentRange('verse_1_1')!; env.component.editor!.setSelection(range.index + 1, 'user'); tick(); env.fixture.detectChanges(); @@ -724,7 +724,7 @@ describe('TextComponent', () => { const remotePresence = 'remote-person-1'; const remoteSegmentRef = 'verse_1_1'; - const remoteRange: RangeStatic | undefined = env.component.getSegmentRange(remoteSegmentRef); + const remoteRange: QuillRange | undefined = env.component.getSegmentRange(remoteSegmentRef); env.addRemotePresence(remotePresence, remoteRange); expect(env.component.editor!.root.scrollTop).toEqual(0); const presenceData: PresenceData = { @@ -784,13 +784,13 @@ describe('TextComponent', () => { expect(env.component.getSegmentText(targetSegmentRef)).withContext('setup').toEqual(initialTextInDoc); expect(env.component.editor!.getText()).withContext('setup').toContain(initialTextInDoc); - const targetSegmentRange: RangeStatic | undefined = env.component.getSegmentRange(targetSegmentRef); + const targetSegmentRange: QuillRange | undefined = env.component.getSegmentRange(targetSegmentRef); if (targetSegmentRange == null) throw Error('setup'); const selectionStart: number = targetSegmentRange.index + 'ta'.length; const selectionLength: number = 'rg'.length; // A couple characters in the segment are selected. env.component.editor?.setSelection(selectionStart, selectionLength); - const originalSelection: RangeStatic | null = env.component.editor!.getSelection(); + const originalSelection: QuillRange | null = env.component.editor!.getSelection(); if (originalSelection == null) throw Error('setup'); // When the user drops text into their browser, a DropEvent gives details on the data being dropped, as well as @@ -832,7 +832,7 @@ describe('TextComponent', () => { // event.preventDefault() should have been called to prevent the browser from doing its own drag-and-drop. expect(cancelled).toBeTrue(); - const resultingSelection: RangeStatic | null = env.component.editor!.getSelection(); + const resultingSelection: QuillRange | null = env.component.editor!.getSelection(); if (resultingSelection == null) throw Error(); expect(resultingSelection) .withContext('canceled drop should not have made the selection change') @@ -917,7 +917,7 @@ describe('TextComponent', () => { env.waitForEditor(); env.component.setSegment(segmentRef); tick(); - const segmentRange: RangeStatic | undefined = env.component.getSegmentRange(segmentRef); + const segmentRange: QuillRange | undefined = env.component.getSegmentRange(segmentRef); if (segmentRange == null) { fail('setup'); return; @@ -925,7 +925,7 @@ describe('TextComponent', () => { // Is a given selection range valid for the current segment (segmentRef)? - const cases: { description: string; range: RangeStatic; shouldBeValid: boolean }[] = [ + const cases: { description: string; range: QuillRange; shouldBeValid: boolean }[] = [ { description: 'entire segment', range: segmentRange, shouldBeValid: true }, { description: 'at first char', range: { index: segmentRange.index, length: 0 }, shouldBeValid: true }, { description: 'at second char', range: { index: segmentRange.index + 1, length: 0 }, shouldBeValid: true }, @@ -991,7 +991,7 @@ describe('TextComponent', () => { shouldBeValid: false } ]; - cases.forEach((testCase: { description: string; range: RangeStatic; shouldBeValid: boolean }) => { + cases.forEach((testCase: { description: string; range: QuillRange; shouldBeValid: boolean }) => { expect((env.component as any).isValidSelectionForCurrentSegment(testCase.range)) .withContext(testCase.description) .toEqual(testCase.shouldBeValid); @@ -999,7 +999,7 @@ describe('TextComponent', () => { })); it('does not cancel in beforeinput when valid selection', fakeAsync(() => { - const { env }: { env: TestEnvironment; segmentRange: RangeStatic } = basicSimpleText(); + const { env }: { env: TestEnvironment; segmentRange: QuillRange } = basicSimpleText(); const beforeinputEvent: InputEvent = new InputEvent('beforeinput', { cancelable: true @@ -1019,7 +1019,7 @@ describe('TextComponent', () => { })); it('cancels in beforeinput when invalid selection', fakeAsync(() => { - const { env }: { env: TestEnvironment; segmentRange: RangeStatic } = basicSimpleText(); + const { env }: { env: TestEnvironment; segmentRange: QuillRange } = basicSimpleText(); const beforeinputEvent: InputEvent = new InputEvent('beforeinput', { cancelable: true @@ -1039,7 +1039,7 @@ describe('TextComponent', () => { })); it('allows backspace when valid selection', fakeAsync(() => { - const { env, segmentRange }: { env: TestEnvironment; segmentRange: RangeStatic } = basicSimpleText(); + const { env, segmentRange }: { env: TestEnvironment; segmentRange: QuillRange } = basicSimpleText(); // When asked, the current selection will be called valid. const isValidSpy: jasmine.Spy = spyOn(env.component, 'isValidSelectionForCurrentSegment').and.returnValue( @@ -1054,7 +1054,7 @@ describe('TextComponent', () => { })); it('disallows backspace when invalid selection', fakeAsync(() => { - const { env, segmentRange }: { env: TestEnvironment; segmentRange: RangeStatic } = basicSimpleText(); + const { env, segmentRange }: { env: TestEnvironment; segmentRange: QuillRange } = basicSimpleText(); // When asked, the current selection will be called invalid. const isValidSpy: jasmine.Spy = spyOn(env.component, 'isValidSelectionForCurrentSegment').and.returnValue( @@ -1069,15 +1069,15 @@ describe('TextComponent', () => { })); it('can backspace a word at a time', fakeAsync(() => { - const { env, segmentRange }: { env: TestEnvironment; segmentRange: RangeStatic } = basicSimpleText(); + const { env, segmentRange }: { env: TestEnvironment; segmentRange: QuillRange } = basicSimpleText(); let initialText = 'quick brown fox'; let resultTexts = ['quick brown ', 'quick ', '', '']; - env.performDeleteWordTest('backspace', segmentRange.index, initialText, resultTexts); + env.performDeleteWordTest('Backspace', segmentRange.index, initialText, resultTexts); TestEnvironment.waitForPresenceTimer(); })); it('allows delete when valid selection', fakeAsync(() => { - const { env, segmentRange }: { env: TestEnvironment; segmentRange: RangeStatic } = basicSimpleText(); + const { env, segmentRange }: { env: TestEnvironment; segmentRange: QuillRange } = basicSimpleText(); // When asked, the current selection will be called valid. const isValidSpy: jasmine.Spy = spyOn(env.component, 'isValidSelectionForCurrentSegment').and.returnValue( @@ -1092,7 +1092,7 @@ describe('TextComponent', () => { })); it('disallows delete when invalid selection', fakeAsync(() => { - const { env, segmentRange }: { env: TestEnvironment; segmentRange: RangeStatic } = basicSimpleText(); + const { env, segmentRange }: { env: TestEnvironment; segmentRange: QuillRange } = basicSimpleText(); // When asked, the current selection will be called invalid. const isValidSpy: jasmine.Spy = spyOn(env.component, 'isValidSelectionForCurrentSegment').and.returnValue( @@ -1107,10 +1107,10 @@ describe('TextComponent', () => { })); it('can delete a word at a time', fakeAsync(() => { - const { env, segmentRange }: { env: TestEnvironment; segmentRange: RangeStatic } = basicSimpleText(); + const { env, segmentRange }: { env: TestEnvironment; segmentRange: QuillRange } = basicSimpleText(); let initialText = 'quick brown fox'; const resultTexts = [' brown fox', ' fox', '', '']; - env.performDeleteWordTest('delete', segmentRange.index, initialText, resultTexts); + env.performDeleteWordTest('Delete', segmentRange.index, initialText, resultTexts); TestEnvironment.waitForPresenceTimer(); })); @@ -1120,7 +1120,7 @@ describe('TextComponent', () => { env.component.id = new TextDocId('project01', 40, 1); env.waitForEditor(); - const range: RangeStatic = env.component.getSegmentRange('verse_1_1')!; + const range: QuillRange = env.component.getSegmentRange('verse_1_1')!; const initialContents: string = env.component.getSegmentText('verse_1_1'); env.component.editor!.setSelection(range.index, 'user'); tick(); @@ -1128,12 +1128,14 @@ describe('TextComponent', () => { const backslashKeyCode = 220; const backslashBindings = env.component.editor!.keyboard['bindings'][backslashKeyCode]; expect(backslashBindings.length).withContext('should have a backslash key handler').toEqual(1); - backslashBindings[0].handler(); + const backslashBinding = backslashBindings[0]; + // Call the handler with the correct `this` context + (backslashBinding.handler as any).call({ quill: env.component.editor }, range, {}); tick(); env.fixture.detectChanges(); // the selection should not have changed - const currentSelection: RangeStatic = env.component.editor!.getSelection()!; + const currentSelection: QuillRange = env.component.editor!.getSelection()!; expect(currentSelection.index).toEqual(range.index); const currentContents: string = env.component.getSegmentText('verse_1_1'); expect(currentContents).withContext('document text should not have changed').toEqual(initialContents); @@ -1145,12 +1147,12 @@ describe('TextComponent', () => { env.component.id = new TextDocId('project01', 40, 1); env.waitForEditor(); - const range: RangeStatic = env.component.getSegmentRange('verse_1_1')!; + const range: QuillRange = env.component.getSegmentRange('verse_1_1')!; env.component.editor!.setSelection(range.index, 0, 'user'); tick(); env.fixture.detectChanges(); const pasteText = '\\back\\slash'; - let contents: DeltaStatic = env.component.getSegmentContents('verse_1_1')!; + let contents: Delta = env.component.getSegmentContents('verse_1_1')!; expect(contents.ops![0].insert).toEqual('target: chapter 1, verse 1.'); const dataTransfer = new DataTransfer(); dataTransfer.setData('text/plain', pasteText); @@ -1169,7 +1171,7 @@ describe('TextComponent', () => { })); it('does not cancel paste when valid selection', fakeAsync(() => { - const { env }: { env: TestEnvironment; segmentRange: RangeStatic } = basicSimpleText(); + const { env }: { env: TestEnvironment; segmentRange: QuillRange } = basicSimpleText(); const payload: string = 'abcd'; const clipboardData = new DataTransfer(); @@ -1193,9 +1195,9 @@ describe('TextComponent', () => { // I haven't been able to trigger quill's onPaste by dispatching a ClipboardEvent. So directly call it. // SUT - (env.component.editor!.clipboard as any).onPaste(pasteEvent); + (env.component.editor!.clipboard as any).onCapturePaste(pasteEvent); flush(); - expect(pasteEvent.defaultPrevented).withContext('the quill onPaste cancels further processing').toBeTrue(); + expect(pasteEvent.defaultPrevented).withContext('the quill onCapturePaste cancels further processing').toBeTrue(); expect(quillUpdateContentsSpy).withContext('quill is edited').toHaveBeenCalled(); expect(isValidSpy).withContext('the test may have worked for the wrong reason').toHaveBeenCalled(); @@ -1204,7 +1206,7 @@ describe('TextComponent', () => { })); it('cancels paste when invalid selection', fakeAsync(() => { - const { env }: { env: TestEnvironment; segmentRange: RangeStatic } = basicSimpleText(); + const { env }: { env: TestEnvironment; segmentRange: QuillRange } = basicSimpleText(); const payload: string = 'abcd'; const clipboardData = new DataTransfer(); @@ -1228,9 +1230,9 @@ describe('TextComponent', () => { // I haven't been able to trigger quill's onPaste by dispatching a ClipboardEvent. So directly call it. // SUT - (env.component.editor!.clipboard as any).onPaste(pasteEvent); + (env.component.editor!.clipboard as any).onCapturePaste(pasteEvent); flush(); - expect(pasteEvent.defaultPrevented).withContext('the quill onPaste cancels further processing').toBeTrue(); + expect(pasteEvent.defaultPrevented).withContext('the quill onCapturePaste cancels further processing').toBeTrue(); expect(quillUpdateContentsSpy).withContext('quill contents are not modified').not.toHaveBeenCalled(); expect(isValidSpy).withContext('the test may have worked for the wrong reason').toHaveBeenCalled(); @@ -1243,8 +1245,8 @@ describe('TextComponent', () => { env.onlineStatus = false; env.waitForEditor(); - let range: RangeStatic = env.component.getSegmentRange('verse_1_1')!; - let verse1Contents: DeltaStatic = env.component.getSegmentContents('verse_1_1')!; + let range: QuillRange = env.component.getSegmentRange('verse_1_1')!; + let verse1Contents: Delta = env.component.getSegmentContents('verse_1_1')!; expect(verse1Contents.ops!.length).toBe(1); env.component.editor!.setSelection(range.index + range.length, 'user'); tick(); @@ -1261,7 +1263,7 @@ describe('TextComponent', () => { tick(); env.fixture.detectChanges(); range = env.component.getSegmentRange('verse_1_3')!; - let verse3Contents: DeltaStatic = env.component.getSegmentContents('verse_1_3')!; + let verse3Contents: Delta = env.component.getSegmentContents('verse_1_3')!; expect(verse3Contents.ops!.length).toBe(1); // delete all the text in the verse env.applyDelta(new Delta().retain(range.index).delete(range.length), 'user'); @@ -1758,7 +1760,7 @@ class TestEnvironment { return (this.component as any).presenceChannel.remotePresences; } - get remoteDocPresences(): Record { + get remoteDocPresences(): Record { return (this.component as any).presenceDoc.remotePresences; } @@ -1766,7 +1768,7 @@ class TestEnvironment { return (this.component as any).localPresenceChannel; } - get localPresenceDoc(): LocalPresence { + get localPresenceDoc(): LocalPresence { return (this.component as any).localPresenceDoc; } @@ -1802,7 +1804,7 @@ class TestEnvironment { this.fixture.detectChanges(); } - applyDelta(delta: DeltaStatic, source: Sources): void { + applyDelta(delta: Delta, source: EmitterSource): void { this.component.editor!.updateContents(delta, source); tick(); this.fixture.detectChanges(); @@ -1826,7 +1828,7 @@ class TestEnvironment { } performDeleteWordTest( - type: 'backspace' | 'delete', + type: 'Backspace' | 'Delete', segmentStartIndex: number, initialText: string, resultTexts: string[] @@ -1834,20 +1836,20 @@ class TestEnvironment { for (let i = 0; i < resultTexts.length; i++) { let content = this.component.editor!.getContents(); expect(content.ops!.length).toEqual(4); - expect(content.ops![1].insert!.verse.number).toEqual('1'); + expect((content.ops![1].insert as any).verse.number).toEqual('1'); expect(this.component.getSegmentText('verse_2_1')).toEqual(initialText); const blankSegmentLength = 1; let selectionIndex: number = segmentStartIndex; - if (type === 'backspace') { + if (type === 'Backspace') { selectionIndex += initialText.length === 0 ? blankSegmentLength : initialText.length; // put the selection at the end of the segment - const selection: RangeStatic = { index: selectionIndex, length: 0 }; + const selection: QuillRange = { index: selectionIndex, length: 0 }; this.quillWordDeletion(type, selection); } else { // put the selection at the beginning of the segment selectionIndex += initialText.length === 0 ? blankSegmentLength : 0; - const selection: RangeStatic = { index: selectionIndex, length: 0 }; + const selection: QuillRange = { index: selectionIndex, length: 0 }; this.quillWordDeletion(type, selection); } tick(); @@ -1855,7 +1857,7 @@ class TestEnvironment { content = this.component.editor!.getContents(); expect(content.ops!.length).toEqual(4); - expect(content.ops![1].insert!.verse.number).toEqual('1'); + expect((content.ops![1].insert as any).verse.number).toEqual('1'); expect(this.component.getSegmentText('verse_2_1')).toEqual(resultTexts[i]); initialText = resultTexts[i]; } @@ -1863,7 +1865,7 @@ class TestEnvironment { /** Write a presence into the sharedb remote presence list, and notify that a new remote presence has * appeared on the textdoc. */ - addRemotePresence(remotePresenceId: string, range?: RangeStatic | null): void { + addRemotePresence(remotePresenceId: string, range?: QuillRange | null): void { const presenceData: PresenceData = { viewer: { activeInEditor: false, @@ -1873,7 +1875,7 @@ class TestEnvironment { } }; if (range == null) { - range = mock(); + range = mock(); } // Write the presence right into the area that would be being provided by the sharedb. this.remoteChannelPresences[remotePresenceId] = presenceData; @@ -1884,27 +1886,28 @@ class TestEnvironment { tick(400); } - /** Dispatching a 'keydown' KeyboardEvent in the test doesn't seem to + /** + * Dispatching a 'keydown' KeyboardEvent in the test doesn't seem to * trigger the quill backspace handler. Crudely go find the desired handler * method in quill keyboard's list of handlers for backspace and call it. - * */ - quillHandleBackspace(range: RangeStatic): boolean { - const backspaceKeyCode = 8; + */ + quillHandleBackspace(range: QuillRange): boolean { + const backspaceKeyCode = 'Backspace'; const matchingBindings = (this.component.editor!.keyboard as any).bindings[backspaceKeyCode].filter( (bindingItem: any) => bindingItem.handler.toString().includes('isBackspaceAllowed') ); expect(matchingBindings.length) .withContext('setup: should be grabbing a single, specific binding in quill with the desired handler') .toEqual(1); - return matchingBindings[0].handler(range); + return matchingBindings[0].handler.call({ quill: this.component.editor }, range, {}); } /** Crudely find backspace or delete word handler. */ - quillWordDeletion(type: 'backspace' | 'delete', range: RangeStatic): void { - let keyCode = 8; + quillWordDeletion(type: 'Backspace' | 'Delete', range: QuillRange): void { + let keyCode = 'Backspace'; let handler = 'handleBackspaceWord'; - if (type === 'delete') { - keyCode = 46; + if (type === 'Delete') { + keyCode = 'Delete'; handler = 'handleDeleteWord'; } const matchingBindings = (this.component.editor!.keyboard as any).bindings[keyCode].filter((bindingItem: any) => @@ -1913,21 +1916,21 @@ class TestEnvironment { expect(matchingBindings.length) .withContext('setup: should be grabbing a single, specific binding in quill with the desired handler') .toEqual(1); - return matchingBindings[0].handler(range); + return matchingBindings[0].handler.call({ quill: this.component.editor }, range, {}); } /** Crudely go find the desired handler * method in quill keyboard's list of handlers for delete and call it. * */ - quillHandleDelete(range: RangeStatic): boolean { - const deleteKeyCode = 46; + quillHandleDelete(range: QuillRange): boolean { + const deleteKeyCode = 'Delete'; const matchingBindings = (this.component.editor!.keyboard as any).bindings[deleteKeyCode].filter( (bindingItem: any) => bindingItem.handler.toString().includes('isDeleteAllowed') ); expect(matchingBindings.length) .withContext('setup: should be grabbing a single, specific binding in quill with the desired handler') .toEqual(1); - return matchingBindings[0].handler(range); + return matchingBindings[0].handler.call({ quill: this.component.editor }, range, {}); } triggerUndo(): void { @@ -1972,7 +1975,7 @@ class TestEnvironment { } } -function basicSimpleText(): { env: TestEnvironment; segmentRange: RangeStatic } { +function basicSimpleText(): { env: TestEnvironment; segmentRange: QuillRange } { const chapterNum = 2; const segmentRef: string = `verse_${chapterNum}_1`; const textDocOps: RichText.DeltaOperation[] = [ @@ -1991,7 +1994,7 @@ function basicSimpleText(): { env: TestEnvironment; segmentRange: RangeStatic } env.waitForEditor(); env.component.setSegment(segmentRef); tick(); - const segmentRange: RangeStatic | undefined = env.component.getSegmentRange(segmentRef); + const segmentRange: QuillRange | undefined = env.component.getSegmentRange(segmentRef); if (segmentRange == null) { fail('setup: problem with segment ref'); throw Error(); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.ts index 11c7f09cc7..f59f640a3d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.ts @@ -13,11 +13,12 @@ import { TranslocoService } from '@ngneat/transloco'; import { Canon, VerseRef } from '@sillsdev/scripture'; import isEqual from 'lodash-es/isEqual'; import merge from 'lodash-es/merge'; -import Quill, { DeltaStatic, RangeStatic, Sources, StringMap } from 'quill'; +import Quill, { Delta, EmitterSource, Range } from 'quill'; import QuillCursors from 'quill-cursors'; import { AuthType, getAuthType } from 'realtime-server/lib/esm/common/models/user'; import { SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role'; import { TextAnchor } from 'realtime-server/lib/esm/scriptureforge/models/text-anchor'; +import { StringMap } from 'rich-text'; import { fromEvent, Subject, Subscription, timer } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { LocalPresence, Presence } from 'sharedb/lib/sharedb'; @@ -29,9 +30,10 @@ import { OnlineStatusService } from 'xforge-common/online-status.service'; import { SubscriptionDisposable } from 'xforge-common/subscription-disposable'; import { UserService } from 'xforge-common/user.service'; import { getBrowserEngine, objectId } from 'xforge-common/utils'; +import { isString } from '../../../type-utils'; import { NoteThreadIcon } from '../../core/models/note-thread-doc'; import { SFProjectProfileDoc } from '../../core/models/sf-project-profile-doc'; -import { Delta, TextDoc, TextDocId } from '../../core/models/text-doc'; +import { TextDoc, TextDocId } from '../../core/models/text-doc'; import { SFProjectService } from '../../core/sf-project.service'; import { TextDocService } from '../../core/text-doc.service'; import { MultiCursorViewer } from '../../translate/editor/multi-viewer/multi-viewer.component'; @@ -42,7 +44,7 @@ import { getVerseStrFromSegmentRef, VERSE_REGEX } from '../utils'; -import { getAttributesAtPosition, registerScripture } from './quill-scripture'; +import { getAttributesAtPosition, getRetainCount, registerScripture } from './quill-scripture'; import { Segment } from './segment'; import { NoteDialogData, TextNoteDialogComponent } from './text-note-dialog/text-note-dialog.component'; import { EditorRange, TextViewModel } from './text-view-model'; @@ -54,7 +56,7 @@ export const EDITOR_READY_TIMEOUT = 100; const USX_FORMATS = registerScripture(); export interface TextUpdatedEvent { - delta?: DeltaStatic; + delta?: Delta; prevSegment?: Segment; segment?: Segment; affectedEmbeds?: EmbedsByVerse[]; @@ -84,7 +86,7 @@ export interface RemotePresences { /** A verse's range and the embeds located within the range. */ export interface EmbedsByVerse { - verseRange: RangeStatic; + verseRange: Range; embeds: Map; } @@ -135,29 +137,29 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn 'embed right shift': null, 'disable backspace': { - key: 'backspace', + key: 'Backspace', altKey: null, ctrlKey: null, metaKey: null, shiftKey: null, - handler: (range: RangeStatic) => this.isBackspaceAllowed(range) + handler: (range: Range) => this.isBackspaceAllowed(range) }, 'disable backspace word': { - key: 'backspace', + key: 'Backspace', ctrlKey: true, - handler: (range: RangeStatic) => this.handleBackspaceWord(range) + handler: (range: Range) => this.handleBackspaceWord(range) }, 'disable delete': { - key: 'delete', - handler: (range: RangeStatic) => this.isDeleteAllowed(range) + key: 'Delete', + handler: (range: Range) => this.isDeleteAllowed(range) }, 'disable delete word': { - key: 'delete', + key: 'Delete', ctrlKey: true, - handler: (range: RangeStatic) => this.handleDeleteWord(range) + handler: (range: Range) => this.handleDeleteWord(range) }, 'disable enter': { - key: 'enter', + key: 'Enter', shiftKey: null, handler: () => false }, @@ -167,7 +169,7 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn handler: () => false }, 'move next, tab': { - key: 'tab', + key: 'Tab', shiftKey: false, handler: () => { if (this.isRtl && this.isSelectionAtSegmentEnd) { @@ -179,12 +181,12 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn } }, 'move prev, tab': { - key: 'tab', + key: 'Tab', shiftKey: true, handler: () => this.movePrevSegment() }, 'move next, segment end, right arrow': { - key: 'right', + key: 'ArrowRight', handler: () => { if (this.isLtr && this.isSelectionAtSegmentEnd) { this.moveNextSegment(false); @@ -197,7 +199,7 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn } }, 'move next, segment end, left arrow': { - key: 'left', + key: 'ArrowLeft', handler: () => { if (this.isRtl && this.isSelectionAtSegmentEnd) { this.moveNextSegment(false); @@ -209,7 +211,7 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn } }, redo: { - key: 'Y', + key: 'y', shortKey: true, handler: () => { if (this.editor != null) { @@ -247,13 +249,13 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn private readonly cursorColorStorageKey = 'cursor_color'; private isDestroyed: boolean = false; private localPresenceChannel?: LocalPresence; - private localPresenceDoc?: LocalPresence; + private localPresenceDoc?: LocalPresence; private readonly presenceId: string = objectId(); /** The ShareDB presence information for the TextDoc that the quill is bound to. */ - private presenceDoc?: Presence; + private presenceDoc?: Presence; private presenceChannel?: Presence; private presenceActiveEditor$: Subject = new Subject(); - private onPresenceDocReceive = (_presenceId: string, _range: RangeStatic | null): void => {}; + private onPresenceDocReceive = (_presenceId: string, _range: Range | null): void => {}; private onPresenceChannelReceive = (_presenceId: string, _presenceData: PresenceData | null): void => {}; constructor( @@ -391,7 +393,7 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn return this._segment; } - get segments(): IterableIterator<[string, RangeStatic]> { + get segments(): IterableIterator<[string, Range]> { return this.viewModel.segments; } @@ -492,7 +494,7 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn this.subscribe(this.onlineStatusService.onlineStatus$, isOnline => { this.changeDetector.detectChanges(); if (!isOnline && this._editor != null) { - const cursors: QuillCursors = this._editor.getModule('cursors'); + const cursors: QuillCursors = this._editor.getModule('cursors') as QuillCursors; cursors.clearCursors(); } }); @@ -550,7 +552,7 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn return false; } - getSegmentRange(ref: string): RangeStatic | undefined { + getSegmentRange(ref: string): Range | undefined { return this.viewModel.getSegmentRange(ref); } @@ -558,7 +560,7 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn return this.viewModel.getSegmentText(ref); } - getSegmentContents(ref: string): DeltaStatic | undefined { + getSegmentContents(ref: string): Delta | undefined { return this.viewModel.getSegmentContents(ref); } @@ -586,7 +588,7 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn return this.editor == null ? null : this.editor.container.querySelector(`usx-segment[data-segment="${segment}"]`); } - getViewerPosition(presenceId: string): RangeStatic | undefined { + getViewerPosition(presenceId: string): Range | undefined { return Object.entries(this.presenceDoc?.remotePresences ?? {}).find(([id, _data]) => id === presenceId)?.[1]; } @@ -659,7 +661,7 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn if (verseSegments.length === 0) { return undefined; } - let editorPosOfSegmentToModify: RangeStatic | undefined = this.getSegmentRange(verseSegments[0]); + let editorPosOfSegmentToModify: Range | undefined = this.getSegmentRange(verseSegments[0]); let startTextPosInVerse: number = textAnchor.start; if (Array.from(this.viewModel.embeddedElements.keys()).includes(id)) { return undefined; @@ -669,7 +671,7 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn const nextSegmentMarkerLength = 1; const blankSegmentLength = 1; for (const vs of verseSegments) { - const editorPosOfSomeSegment: RangeStatic | undefined = this.getSegmentRange(vs); + const editorPosOfSomeSegment: Range | undefined = this.getSegmentRange(vs); if (editorPosOfSomeSegment == null) { break; } @@ -738,7 +740,7 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn } } - get commenterSelection(): RangeStatic[] { + get commenterSelection(): Range[] { const ret = []; for (const segment of this.viewModel.segments) { const range = segment[1]; @@ -754,7 +756,7 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn toggleVerseSelection(verseRef: VerseRef): boolean { if (this.editor == null) return false; const verseSegments: string[] = this.filterSegments(this.getCompatibleSegments(verseRef)); - const verseRange: RangeStatic | undefined = this.getSegmentRange(verseSegments[0]); + const verseRange: Range | undefined = this.getSegmentRange(verseSegments[0]); let selectionValue: true | null = true; if (verseRange != null) { const formats: StringMap = getAttributesAtPosition(this.editor, verseRange.index); @@ -766,7 +768,7 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn for (const segment of verseSegments) { // only underline the selection if it is part of the verse text i.e. not a section heading if (!VERSE_REGEX.test(segment)) continue; - const range: RangeStatic | undefined = this.getSegmentRange(segment); + const range: Range | undefined = this.getSegmentRange(segment); if (range != null) { if (!verseEmbedFormatted) { // add the formatting to the verse embed on the first iteration @@ -780,10 +782,10 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn } /** Respond to text changes in the quill editor. */ - onContentChanged(delta: DeltaStatic, source: string): void { - const preDeltaSegmentCache: IterableIterator<[string, RangeStatic]> = this.viewModel.segmentsSnapshot; + onContentChanged(delta: Delta, source: string): void { + const preDeltaSegmentCache: IterableIterator<[string, Range]> = this.viewModel.segmentsSnapshot; const preDeltaEmbedCache: Readonly> = this.viewModel.embeddedElementsSnapshot; - this.viewModel.update(delta, source as Sources, this.onlineStatusService.isOnline); + this.viewModel.update(delta, source as EmitterSource, this.onlineStatusService.isOnline); // skip updating when only formatting changes occurred if (delta.ops != null && delta.ops.some(op => op.insert != null || op.delete != null)) { const isUserEdit: boolean = source === 'user'; @@ -791,7 +793,7 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn } } - async onSelectionChanged(range: RangeStatic | null): Promise { + async onSelectionChanged(range: Range | null): Promise { this.update(); this.submitLocalPresenceDoc(range); @@ -863,7 +865,7 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn if (presenceId == null) { return; } - const range: RangeStatic | undefined = this.getViewerPosition(presenceId); + const range: Range | undefined = this.getViewerPosition(presenceId); if (range == null) { this.editor.root.scrollTop = 0; return; @@ -882,12 +884,12 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn } isSegmentBlank(ref: string): boolean { - const segmentDelta: DeltaStatic | undefined = this.getSegmentContents(ref); + const segmentDelta: Delta | undefined = this.getSegmentContents(ref); if (segmentDelta?.ops == null) { return false; } for (const op of segmentDelta.ops) { - if (op.insert != null && op.insert.blank != null) { + if (!isString(op.insert) && op.insert?.blank != null) { return true; } } @@ -895,8 +897,8 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn } /** Is a given selection range valid for editing the current segment? */ - isValidSelectionForCurrentSegment(sel: RangeStatic): boolean { - const newSel: RangeStatic | null = this.conformToValidSelectionForCurrentSegment(sel); + isValidSelectionForCurrentSegment(sel: Range): boolean { + const newSel: Range | null = this.conformToValidSelectionForCurrentSegment(sel); return !(newSel == null || sel.index !== newSel.index || sel.length !== newSel.length); } @@ -907,7 +909,7 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn this.focused.emit(focus); } - setContents(delta: DeltaStatic, source?: Sources): void { + setContents(delta: Delta, source?: EmitterSource): void { if (this._editor != null) { this._editor.setContents(delta, source); this.contentSet = true; @@ -942,16 +944,16 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn if (!this.isPresenceEnabled || this.editor == null) { return; } - const cursors: QuillCursors = this.editor.getModule('cursors'); + const cursors: QuillCursors = this.editor.getModule('cursors') as QuillCursors; - // Subscribe to TextDoc specific presence changes - these only include RangeStatic updates from ShareDB + // Subscribe to TextDoc specific presence changes - these only include Range updates from ShareDB this.presenceDoc = textDoc.docPresence; this.presenceDoc.subscribe(error => { if (error) throw error; }); this.localPresenceDoc = this.presenceDoc.create(this.presenceId); - this.onPresenceDocReceive = (presenceId: string, range: RangeStatic | null) => { + this.onPresenceDocReceive = (presenceId: string, range: Range | null) => { if (range == null || !this.isPresenceActive) { cursors.removeCursor(presenceId); return; @@ -1073,7 +1075,7 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn await this.submitLocalPresenceChannel(null); await this.submitLocalPresenceDoc(null); if (this.editor != null) { - const cursors: QuillCursors = this.editor.getModule('cursors'); + const cursors: QuillCursors = this.editor.getModule('cursors') as QuillCursors; cursors.clearCursors(); } this.presenceChannel?.unsubscribe(error => { @@ -1110,7 +1112,7 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn if (this.editor == null || this.segment == null) { return false; } - const selection: RangeStatic | null = this.editor.getSelection(); + const selection: Range | null = this.editor.getSelection(); if (selection == null) { return false; } @@ -1121,15 +1123,14 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn } // Strip embeds from the segment range so we can get can an accurate index and length - const segmentRange: RangeStatic = - this.conformToValidSelectionForCurrentSegment(this.segment.range) ?? this.segment.range; + const segmentRange: Range = this.conformToValidSelectionForCurrentSegment(this.segment.range) ?? this.segment.range; const selectionEndIndex = selection.index + (end ? selection.length : 0); const segmentEndIndex = segmentRange.index + (end ? segmentRange.length : 0); return selectionEndIndex === segmentEndIndex; } - private isBackspaceAllowed(range: RangeStatic): boolean { + private isBackspaceAllowed(range: Range): boolean { if (this._editor == null) { return false; } @@ -1148,26 +1149,26 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn return isTextDeletion && this._segment != null && range.index !== this._segment.range.index; } - private handleBackspaceWord(range: RangeStatic): boolean { + private handleBackspaceWord(range: Range): boolean { if (range.length > 0 || this._editor == null) return false; - const wordRange: RangeStatic | undefined = this.getRangeForWordBeforeIndex(range.index); + const wordRange: Range | undefined = this.getRangeForWordBeforeIndex(range.index); if (wordRange != null) { this._editor.deleteText(wordRange.index, wordRange.length, 'user'); } return false; } - private handleDeleteWord(range: RangeStatic): boolean { + private handleDeleteWord(range: Range): boolean { if (range.length > 0 || this._editor == null) return false; - const wordRange: RangeStatic | undefined = this.getRangeForWordAfterIndex(range.index); + const wordRange: Range | undefined = this.getRangeForWordAfterIndex(range.index); if (wordRange != null) { this._editor.deleteText(wordRange.index, wordRange.length, 'user'); } return false; } - private isDeleteAllowed(range: RangeStatic): boolean { + private isDeleteAllowed(range: Range): boolean { if (this._editor == null) { return false; } @@ -1188,13 +1189,13 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn ); } - private getRangeForWordBeforeIndex(selectionIndex: number): RangeStatic | undefined { + private getRangeForWordBeforeIndex(selectionIndex: number): Range | undefined { if (this.segment == null || this._editor == null) return undefined; - const segmentRange: RangeStatic | undefined = this.getSegmentRange(this.segment.ref); + const segmentRange: Range | undefined = this.getSegmentRange(this.segment.ref); if (segmentRange == null) return undefined; const lengthFromSegmentStartToSelection: number = selectionIndex - segmentRange.index; - const contents: DeltaStatic = this._editor.getContents(segmentRange.index, lengthFromSegmentStartToSelection); + const contents: Delta = this._editor.getContents(segmentRange.index, lengthFromSegmentStartToSelection); if (contents.ops == null) return undefined; const lastOp: any = contents.ops[contents.ops.length - 1].insert; @@ -1215,13 +1216,13 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn return { index: startOfWordIndex, length: wordLength }; } - private getRangeForWordAfterIndex(selectionIndex: number): RangeStatic | undefined { + private getRangeForWordAfterIndex(selectionIndex: number): Range | undefined { if (this.segment == null || this._editor == null) return undefined; - const segmentRange: RangeStatic | undefined = this.getSegmentRange(this.segment.ref); + const segmentRange: Range | undefined = this.getSegmentRange(this.segment.ref); if (segmentRange == null) return undefined; const lengthToSegmentEnd: number = segmentRange.index + segmentRange.length - selectionIndex; - const contents: DeltaStatic = this._editor.getContents(selectionIndex, lengthToSegmentEnd); + const contents: Delta = this._editor.getContents(selectionIndex, lengthToSegmentEnd); if (contents.ops == null || contents.ops.length < 1) return undefined; const firstOp = contents.ops[0].insert; @@ -1297,7 +1298,7 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn }); } - private async submitLocalPresenceDoc(range: RangeStatic | null): Promise { + private async submitLocalPresenceDoc(range: Range | null): Promise { if ( !this.isPresenceActive || this.localPresenceDoc == null || @@ -1312,8 +1313,8 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn } private update( - delta?: DeltaStatic, - preDeltaSegmentCache?: IterableIterator<[string, RangeStatic]>, + delta?: Delta, + preDeltaSegmentCache?: IterableIterator<[string, Range]>, preDeltaEmbedCache?: Readonly>, isUserEdit?: boolean ): void { @@ -1344,8 +1345,8 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn // Embedding notes into quill makes quill emit deltas when it registers that content has changed // but quill incorrectly interprets the change when the selection is within the updated segment. // Content coming after the selection gets moved before the selection. This moves the selection back. - const curSegmentRange: RangeStatic = this.segment.range; - const insertionPoint: number = delta.ops[0].retain; + const curSegmentRange: Range = this.segment.range; + const insertionPoint: number = getRetainCount(delta.ops[0]) ?? 0; const segmentEndPoint: number = curSegmentRange.index + curSegmentRange.length - 1; if (insertionPoint >= curSegmentRange.index && insertionPoint <= segmentEndPoint) { this._editor.setSelection(segmentEndPoint); @@ -1463,7 +1464,7 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn return; } - const segmentRange: RangeStatic | undefined = this.viewModel.getSegmentRange(this._segment.ref); + const segmentRange: Range | undefined = this.viewModel.getSegmentRange(this._segment.ref); if (segmentRange == null) { return; } @@ -1494,8 +1495,8 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn /** Gets the embeds affected */ private getEmbedsAffectedByDelta( - delta?: DeltaStatic, - preDeltaSegmentCache?: IterableIterator<[string, RangeStatic]>, + delta?: Delta, + preDeltaSegmentCache?: IterableIterator<[string, Range]>, preDeltaEmbedCache?: Readonly> ): EmbedsByVerse[] { if (delta?.ops == null || preDeltaSegmentCache == null || preDeltaEmbedCache == null) { @@ -1503,7 +1504,7 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn } let verseIsEdited = false; let currentVerse: string = ''; - let currentVerseRange: RangeStatic = { index: 0, length: 0 }; + let currentVerseRange: Range = { index: 0, length: 0 }; let embedsByVerse = new Map(); const editPositions: number[] = this.getEditPositionsInDelta(delta); const embedsByEditedVerse: EmbedsByVerse[] = []; @@ -1552,7 +1553,7 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn return embedsByEditedVerse; } - private getEditPositionsInDelta(delta: DeltaStatic): number[] { + private getEditPositionsInDelta(delta: Delta): number[] { let curIndex = 0; const editPositions: number[] = []; if (delta.ops == null) { @@ -1567,7 +1568,7 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn curIndex += op.delete; } else { // increase the current index by the value in the retain - curIndex += op.retain == null ? 0 : op.retain; + curIndex += getRetainCount(op) ?? 0; } } return editPositions; @@ -1578,12 +1579,12 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn if (this._editor == null) { return; } - const sel: RangeStatic | null = this._editor.getSelection(); + const sel: Range | null = this._editor.getSelection(); if (sel == null) { return; } - const newSel: RangeStatic | null = this.conformToValidSelectionForCurrentSegment(sel); + const newSel: Range | null = this.conformToValidSelectionForCurrentSegment(sel); if (newSel != null && (sel.index !== newSel.index || sel.length !== newSel.length)) { this._editor.setSelection(newSel, 'user'); } @@ -1591,11 +1592,11 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn /** Given a selection, return a possibly modified selection that is a valid for editing the current segment. * For example, a selection over a segment boundary is sometimes not valid. */ - private conformToValidSelectionForCurrentSegment(sel: RangeStatic): RangeStatic | null { + private conformToValidSelectionForCurrentSegment(sel: Range): Range | null { if (this._editor == null || this._segment == null) { return null; } - let newSel: RangeStatic | undefined; + let newSel: Range | undefined; if (this._segment.text === '') { // always select at the end of blank so the cursor is inside the segment and not between the segment and verse newSel = { index: this._segment.range.index + this._segment.range.length, length: 0 }; @@ -1634,7 +1635,7 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn if (this._editor == null) { return; } - const sel: RangeStatic | null = this._editor.getSelection(); + const sel: Range | null = this._editor.getSelection(); if (sel == null) { return; } @@ -1675,9 +1676,9 @@ export class TextComponent extends SubscriptionDisposable implements AfterViewIn this.highlightMarker.style.marginTop = -this._selectionBoundsTop + 'px'; const height = this.highlightMarkerHeight + offsetTop; this.highlightMarker.style.height = Math.max(height, 0) + 'px'; - } else if (offsetBottom > this._editor.scrollingContainer.clientHeight) { + } else if (offsetBottom > this._editor.root.clientHeight) { this.highlightMarker.style.marginTop = marginTop + 'px'; - const height = this._editor.scrollingContainer.clientHeight - offsetTop; + const height = this._editor.root.clientHeight - offsetTop; this.highlightMarker.style.height = Math.max(height, 0) + 'px'; } else { this.highlightMarker.style.marginTop = marginTop + 'px'; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/utils.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/utils.spec.ts index 29873ff588..afb9b340c8 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/utils.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/utils.spec.ts @@ -47,7 +47,7 @@ describe('shared utils', () => { it('requires op.insert to be a string or object', () => { expect(isBadDelta([{}])).toBeTrue(); expect(isBadDelta([{ insert: null }])).toBeTrue(); - expect(isBadDelta([{ insert: 1 }])).toBeTrue(); + expect(isBadDelta([{ insert: 1 as any }])).toBeTrue(); // this isn't actually a good op, but isBadDelta won't see a problem with it // it's looking for known issues, not proving validity expect(isBadDelta([{ insert: {} }])).toBeFalse(); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/utils.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/utils.ts index b35bb01433..1e80dfa48b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/utils.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/utils.ts @@ -212,7 +212,7 @@ export function projectLabel(project: SelectableProject | DraftSource | undefine * @param ops An array of ops to check. */ export function isBadDelta(ops: DeltaOperation[]): boolean { - const chapterInsertsCount = ops.filter(op => op.insert?.chapter != null).length; + const chapterInsertsCount = ops.filter(op => (op?.insert as any)?.chapter != null).length; const containsBadOp = ops.some( op => // insert must be defined for any op, and can't be nullish @@ -249,18 +249,18 @@ export function getUnsupportedTags(deltaOp: DeltaOperation): string[] { deltaOp.forEach(t => getUnsupportedTags(t).forEach(s => invalidTags.add(s))); } else if (deltaOp && typeof deltaOp === 'object') { if (deltaOp.attributes?.['invalid-block'] !== undefined || deltaOp.attributes?.['invalid-inline'] !== undefined) { - let style = deltaOp.attributes?.char?.style; + let style = (deltaOp.attributes?.char as any)?.style; if (style !== undefined) { invalidTags.add(style); } else { - style = deltaOp.attributes?.para?.style; + style = (deltaOp.attributes?.para as any)?.style; if (style !== undefined) { invalidTags.add(style); } } } - Object.values(deltaOp).forEach(v => getUnsupportedTags(v).forEach(s => invalidTags.add(s))); + Object.values(deltaOp).forEach(v => getUnsupportedTags(v as any).forEach(s => invalidTags.add(s))); } return [...invalidTags]; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/text-chooser-dialog/text-chooser-dialog.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/text-chooser-dialog/text-chooser-dialog.component.spec.ts index 13860b6170..a348cead5f 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/text-chooser-dialog/text-chooser-dialog.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/text-chooser-dialog/text-chooser-dialog.component.spec.ts @@ -7,7 +7,7 @@ import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { VerseRef } from '@sillsdev/scripture'; import { CookieService } from 'ngx-cookie-service'; -import { DeltaStatic } from 'quill'; +import { Delta } from 'quill'; import { User } from 'realtime-server/lib/esm/common/models/user'; import { createTestUser } from 'realtime-server/lib/esm/common/models/user-test-data'; import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; @@ -32,7 +32,7 @@ import { UserService } from 'xforge-common/user.service'; import { CheckingModule } from '../checking/checking.module'; import { SFProjectProfileDoc } from '../core/models/sf-project-profile-doc'; import { SF_TYPE_REGISTRY } from '../core/models/sf-type-registry'; -import { Delta, TextDoc } from '../core/models/text-doc'; +import { TextDoc } from '../core/models/text-doc'; import { SFProjectService } from '../core/sf-project.service'; import { TextChooserDialogComponent, TextChooserDialogData, TextSelection } from './text-chooser-dialog.component'; @@ -384,7 +384,7 @@ class TestEnvironment { static segmentLen(verseNumber: number): number { return TestEnvironment.delta.filter( op => op.attributes != null && op.attributes.segment === 'verse_1_' + verseNumber - )[0].insert.length; + )[0].insert.length as number; } readonly fixture: ComponentFixture; @@ -537,7 +537,7 @@ class TestEnvironment { return div.firstChild! as Element; } - static get delta(): DeltaStatic { + static get delta(): Delta { const delta = new Delta(); delta.insert({ chapter: { number: '1', style: 'c' } }); delta.insert('heading text', { para: { style: 'p' } }); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.spec.ts index bf99d89449..a3ee5ed006 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.spec.ts @@ -1,6 +1,7 @@ import { TestBed } from '@angular/core/testing'; +import { Delta } from 'quill'; import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; -import { Delta, DeltaOperation } from 'rich-text'; +import { DeltaOperation } from 'rich-text'; import { of, throwError } from 'rxjs'; import { anything, instance, mock, verify, when } from 'ts-mockito'; import { ErrorReportingService } from 'xforge-common/error-reporting.service'; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.ts index 87932e58cb..c97f7947a0 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-handling.service.ts @@ -1,11 +1,12 @@ import { Injectable } from '@angular/core'; import { VerseRef } from '@sillsdev/scripture'; -import { DeltaOperation, DeltaStatic } from 'quill'; +import { Delta } from 'quill'; import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; +import { DeltaOperation } from 'rich-text'; import { catchError, Observable, throwError } from 'rxjs'; import { ErrorReportingService } from 'xforge-common/error-reporting.service'; import { isString } from '../../../type-utils'; -import { Delta, TextDocId } from '../../core/models/text-doc'; +import { TextDocId } from '../../core/models/text-doc'; import { SFProjectService } from '../../core/sf-project.service'; import { TextDocService } from '../../core/text-doc.service'; import { getVerseRefFromSegmentRef, isBadDelta, verseSlug } from '../../shared/utils'; @@ -18,7 +19,7 @@ export interface DraftMappingOptions { export interface DraftDiff { id: TextDocId; - ops: DeltaStatic; + ops: Delta; } @Injectable({ @@ -48,12 +49,13 @@ export class DraftHandlingService { return false; } - const draftSegmentText: string | undefined = draft[op.attributes?.segment]; + const draftSegmentText: string | undefined = draft[op.attributes?.segment as string]; const isSegmentDraftAvailable = draftSegmentText != null && draftSegmentText.trim().length > 0; // Can populate draft if insert is a blank string OR insert is object that has 'blank: true' property. // Other objects are not draftable (e.g. 'note-thread-embed'). - const isInsertBlank = (isString(op.insert) && op.insert.trim().length === 0) || op.insert.blank === true; + const isInsertBlank = + (isString(op.insert) && op.insert.trim().length === 0) || (!isString(op.insert) && op.insert.blank === true); return isSegmentDraftAvailable && isInsertBlank; }); @@ -74,7 +76,7 @@ export class DraftHandlingService { const overwrite = options?.overwrite ?? false; return targetOps.map(op => { - const segmentRef: string | undefined = op.attributes?.segment; + const segmentRef: string | undefined = op.attributes?.segment as string | undefined; if (segmentRef == null) return op; let draftSegmentText: string | undefined = draft[segmentRef]; let isSegmentDraftAvailable = draftSegmentText != null && draftSegmentText.trim().length > 0; @@ -187,7 +189,7 @@ export class DraftHandlingService { * @param textDocId The text doc identifier. * @param draftDelta The draft delta to overwrite the current text document with. */ - async applyChapterDraftAsync(textDocId: TextDocId, draftDelta: DeltaStatic): Promise { + async applyChapterDraftAsync(textDocId: TextDocId, draftDelta: Delta): Promise { await this.projectService.onlineSetDraftApplied(textDocId.projectId, textDocId.bookNum, textDocId.chapterNum, true); await this.projectService.onlineSetIsValid(textDocId.projectId, textDocId.bookNum, textDocId.chapterNum, true); await this.textDocService.overwrite(textDocId, draftDelta, 'Draft'); @@ -222,7 +224,7 @@ export class DraftHandlingService { } else { ops = draft; } - const draftDelta: DeltaStatic = new Delta(ops); + const draftDelta: Delta = new Delta(ops); await this.applyChapterDraftAsync(targetTextDocId, draftDelta).catch(err => { // report the error to bugsnag this.errorReportingService.silentError('Error applying a draft', ErrorReportingService.normalizeError(err)); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.spec.ts index 81b7100eba..0aade17dfd 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.spec.ts @@ -2,8 +2,8 @@ import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testin import { MatProgressBarModule } from '@angular/material/progress-bar'; import { cloneDeep } from 'lodash-es'; import { TranslocoMarkupModule } from 'ngx-transloco-markup'; +import { Delta } from 'quill'; import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; -import { Delta } from 'rich-text'; import { of } from 'rxjs'; import { anything, mock, verify, when } from 'ts-mockito'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.ts index 34b611f78b..bfe4238f6b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.ts @@ -1,6 +1,6 @@ import { AfterViewInit, Component, DestroyRef, EventEmitter, Input, OnChanges, ViewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { DeltaStatic } from 'quill'; +import { Delta } from 'quill'; import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; import { DeltaOperation } from 'rich-text'; import { @@ -25,7 +25,7 @@ import { I18nService } from 'xforge-common/i18n.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { filterNullish } from 'xforge-common/util/rxjs-util'; import { isString } from '../../../../type-utils'; -import { Delta, TextDocId } from '../../../core/models/text-doc'; +import { TextDocId } from '../../../core/models/text-doc'; import { SFProjectService } from '../../../core/sf-project.service'; import { TextComponent } from '../../../shared/text/text.component'; import { DraftGenerationService } from '../../draft-generation/draft-generation.service'; @@ -55,8 +55,8 @@ export class EditorDraftComponent implements AfterViewInit, OnChanges { isDraftApplied = false; userAppliedDraft = false; - private draftDelta?: DeltaStatic; - private targetDelta?: DeltaStatic; + private draftDelta?: Delta; + private targetDelta?: Delta; constructor( private readonly activatedProjectService: ActivatedProjectService, @@ -193,7 +193,8 @@ export class EditorDraftComponent implements AfterViewInit, OnChanges { return false; } - const isInsertBlank = (isString(op.insert) && op.insert.trim().length === 0) || op.insert.blank === true; + const isInsertBlank = + (isString(op.insert) && op.insert.trim().length === 0) || (!isString(op.insert) && op.insert.blank === true); return !isInsertBlank; }); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.spec.ts index 5c56e58e96..15fac63418 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.spec.ts @@ -1,6 +1,6 @@ import { EventEmitter, NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; -import Quill from 'quill'; +import Quill, { Delta } from 'quill'; import { TextData } from 'realtime-server/lib/esm/scriptureforge/models/text-data'; import { Subject } from 'rxjs'; import { anything, mock, when } from 'ts-mockito'; @@ -10,7 +10,7 @@ import { OnlineStatusService } from 'xforge-common/online-status.service'; import { TestOnlineStatusModule } from 'xforge-common/test-online-status.module'; import { TestOnlineStatusService } from 'xforge-common/test-online-status.service'; import { configureTestingModule } from 'xforge-common/test-utils'; -import { Delta, TextDoc, TextDocId } from '../../../core/models/text-doc'; +import { TextDoc, TextDocId } from '../../../core/models/text-doc'; import { Revision } from '../../../core/paratext.service'; import { SFProjectService } from '../../../core/sf-project.service'; import { NoticeComponent } from '../../../shared/notice/notice.component'; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.ts index 2e4b9656c4..ab5a87bd64 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.ts @@ -10,13 +10,13 @@ import { ViewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { DeltaStatic } from 'quill'; +import { Delta } from 'quill'; import { combineLatest, startWith, tap } from 'rxjs'; import { FontService } from 'xforge-common/font.service'; import { I18nService } from 'xforge-common/i18n.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; -import { Delta, TextDoc } from '../../../core/models/text-doc'; +import { TextDoc } from '../../../core/models/text-doc'; import { Revision } from '../../../core/paratext.service'; import { SFProjectService } from '../../../core/sf-project.service'; import { TextComponent } from '../../../shared/text/text.component'; @@ -90,14 +90,14 @@ export class EditorHistoryComponent implements OnChanges, OnInit, AfterViewInit ]) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(async ([e, showDiff]: [RevisionSelectEvent, boolean]) => { - let snapshotContents: DeltaStatic = new Delta(e.snapshot?.data.ops); + let snapshotContents: Delta = new Delta(e.snapshot?.data.ops); this.snapshotText?.setContents(snapshotContents, 'api'); this.loadedRevision = e.revision; // Show the diff, if requested if (showDiff && this.diffText?.id != null) { const textDoc: TextDoc = await this.projectService.getText(this.diffText.id); - const targetContents: DeltaStatic = new Delta(textDoc.data?.ops); + const targetContents: Delta = new Delta(textDoc.data?.ops); const diff = this.editorHistoryService.processDiff(snapshotContents, targetContents); this.snapshotText?.editor?.updateContents(diff, 'api'); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.service.spec.ts index 9c5cc175a3..655ef39750 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.service.spec.ts @@ -1,5 +1,5 @@ import { TestBed } from '@angular/core/testing'; -import { Delta } from 'rich-text'; +import { Delta } from 'quill'; import { mock, when } from 'ts-mockito'; import { I18nService } from 'xforge-common/i18n.service'; import { configureTestingModule } from 'xforge-common/test-utils'; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.service.ts index 3687e88083..e7967d4161 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { DeltaStatic } from 'quill'; +import { Delta } from 'quill'; import { I18nService } from 'xforge-common/i18n.service'; const MILLISECONDS_IN_A_DAY = 24 * 60 * 60 * 1000; @@ -37,12 +37,12 @@ export class EditorHistoryService { return 'Invalid Date'; } - processDiff(deltaA: DeltaStatic, deltaB: DeltaStatic): DeltaStatic { + processDiff(deltaA: Delta, deltaB: Delta): Delta { // Remove the cid whenever it is found, as this is confusing the diff deltaA.forEach(obj => this.removeCid(obj)); deltaB.forEach(obj => this.removeCid(obj)); - let diff: DeltaStatic = deltaA.diff(deltaB); + let diff: Delta = deltaA.diff(deltaB); // Process each op in the diff for (const op of diff.ops ?? []) { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.ts index 9249c57efe..e4cc932242 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.ts @@ -2,7 +2,7 @@ import { AfterViewInit, Component, EventEmitter, Input, OnChanges, Output, Simpl import { MatSelectChange } from '@angular/material/select'; import { translate } from '@ngneat/transloco'; import { Canon } from '@sillsdev/scripture'; -import { DeltaStatic } from 'quill'; +import { Delta } from 'quill'; import { TextData } from 'realtime-server/lib/esm/scriptureforge/models/text-data'; import { BehaviorSubject, @@ -21,7 +21,7 @@ import { TextSnapshot } from 'xforge-common/models/textsnapshot'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { SFProjectProfileDoc } from '../../../../core/models/sf-project-profile-doc'; -import { Delta, TextDocId } from '../../../../core/models/text-doc'; +import { TextDocId } from '../../../../core/models/text-doc'; import { ParatextService, Revision } from '../../../../core/paratext.service'; import { SFProjectService } from '../../../../core/sf-project.service'; import { TextDocService } from '../../../../core/text-doc.service'; @@ -157,7 +157,7 @@ export class HistoryChooserComponent implements AfterViewInit, OnChanges { } // Revert to the snapshot - const delta: DeltaStatic = new Delta(this.selectedSnapshot.data.ops); + const delta: Delta = new Delta(this.selectedSnapshot.data.ops); const textDocId = new TextDocId(this.projectId, this.bookNum, this.chapter, 'target'); await this.textDocService.overwrite(textDocId, delta, 'History'); await this.projectService.onlineSetIsValid( diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.spec.ts index f5d9b8324f..32cad59609 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.spec.ts @@ -22,11 +22,12 @@ import { WordGraphArc } from '@sillsdev/machine'; import { Canon, VerseRef } from '@sillsdev/scripture'; +import userEvent, { UserEvent } from '@testing-library/user-event'; import { merge } from 'lodash-es'; import cloneDeep from 'lodash-es/cloneDeep'; import { CookieService } from 'ngx-cookie-service'; import { TranslocoMarkupModule } from 'ngx-transloco-markup'; -import Quill, { DeltaOperation, DeltaStatic, RangeStatic, Sources, StringMap } from 'quill'; +import Quill, { Delta, EmitterSource, Range } from 'quill'; import { User } from 'realtime-server/lib/esm/common/models/user'; import { createTestUser } from 'realtime-server/lib/esm/common/models/user-test-data'; import { WritingSystem } from 'realtime-server/lib/esm/common/models/writing-system'; @@ -60,6 +61,7 @@ import { TextType } from 'realtime-server/lib/esm/scriptureforge/models/text-dat import { TextInfoPermission } from 'realtime-server/lib/esm/scriptureforge/models/text-info-permission'; import { fromVerseRef } from 'realtime-server/lib/esm/scriptureforge/models/verse-ref-data'; import * as RichText from 'rich-text'; +import { DeltaOperation, StringMap } from 'rich-text'; import { BehaviorSubject, defer, firstValueFrom, Observable, of, Subject, take } from 'rxjs'; import { anything, capture, deepEqual, instance, mock, resetCalls, verify, when } from 'ts-mockito'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; @@ -85,7 +87,7 @@ import { SFProjectDoc } from '../../core/models/sf-project-doc'; import { SFProjectProfileDoc } from '../../core/models/sf-project-profile-doc'; import { SFProjectUserConfigDoc } from '../../core/models/sf-project-user-config-doc'; import { SF_TYPE_REGISTRY } from '../../core/models/sf-type-registry'; -import { Delta, TextDoc, TextDocId } from '../../core/models/text-doc'; +import { TextDoc, TextDocId } from '../../core/models/text-doc'; import { ParatextService } from '../../core/paratext.service'; import { PermissionsService } from '../../core/permissions.service'; import { SFProjectService } from '../../core/sf-project.service'; @@ -398,14 +400,14 @@ describe('EditorComponent', () => { let segmentRange = env.component.target!.segment!.range; let segmentContents = env.targetEditor.getContents(segmentRange.index, segmentRange.length); let op = segmentContents.ops![0]; - expect(op.insert.blank).toBe(true); + expect((op.insert as any).blank).toBe(true); expect(op.attributes!.segment).toEqual('p_1'); const index = env.typeCharacters('t'); segmentRange = env.component.target!.segment!.range; segmentContents = env.targetEditor.getContents(segmentRange.index, segmentRange.length); op = segmentContents.ops![0]; - expect(op.insert.blank).toBeUndefined(); + expect((op.insert as any).blank).toBeUndefined(); expect(op.attributes!.segment).toEqual('p_1'); env.targetEditor.setSelection(index - 2, 1, 'user'); @@ -413,7 +415,7 @@ describe('EditorComponent', () => { segmentRange = env.component.target!.segment!.range; segmentContents = env.targetEditor.getContents(segmentRange.index, segmentRange.length); op = segmentContents.ops![0]; - expect(op.insert.blank).toBe(true); + expect((op.insert as any).blank).toBe(true); expect(op.attributes!.segment).toEqual('p_1'); env.dispose(); @@ -434,7 +436,7 @@ describe('EditorComponent', () => { } }); op = segmentContents.ops![1]; - expect(op.insert.blank).toBeUndefined(); + expect((op.insert as any).blank).toBeUndefined(); expect(op.attributes!.segment).toEqual('verse_1_4/p_1'); let index = env.targetEditor.getSelection()!.index; @@ -454,7 +456,7 @@ describe('EditorComponent', () => { } }); op = segmentContents.ops![1]; - expect(op.insert.blank).toBeUndefined(); + expect((op.insert as any).blank).toBeUndefined(); expect(op.attributes!.segment).toEqual('verse_1_4/p_1'); env.targetEditor.setSelection(index - 1, 1, 'user'); @@ -472,7 +474,7 @@ describe('EditorComponent', () => { } }); op = segmentContents.ops![1]; - expect(op.insert.blank).toBe(true); + expect((op.insert as any).blank).toBe(true); expect(op.attributes!.segment).toEqual('verse_1_4/p_1'); env.dispose(); @@ -1334,7 +1336,7 @@ describe('EditorComponent', () => { expect(env.targetEditor.history['stack']['undo'].length).withContext('setup').toEqual(0); let range = env.component.target!.getSegmentRange('verse_1_2')!; let contents = env.targetEditor.getContents(range.index, 1); - expect(contents.ops![0].insert.blank).toBeDefined(); + expect((contents.ops![0].insert as any).blank).toBeDefined(); // set selection on a blank segment env.targetEditor.setSelection(range.index, 'user'); @@ -1348,7 +1350,7 @@ describe('EditorComponent', () => { env.pressKey('delete'); expect(env.targetEditor.history['stack']['undo'].length).toEqual(0); contents = env.targetEditor.getContents(range.index, 1); - expect(contents.ops![0].insert.blank).toBeDefined(); + expect((contents.ops![0].insert as any).blank).toBeDefined(); // set selection at segment boundaries range = env.component.target!.getSegmentRange('verse_1_4')!; @@ -1366,12 +1368,12 @@ describe('EditorComponent', () => { env.targetEditor.insertEmbed(range.index, 'note', { caller: 'a', style: 'ft' }, 'api'); env.wait(); contents = env.targetEditor.getContents(range.index, 1); - expect(contents.ops![0].insert.note).toBeDefined(); + expect((contents.ops![0].insert as any).note).toBeDefined(); env.targetEditor.setSelection(range.index + 1, 'user'); env.pressKey('backspace'); expect(env.targetEditor.history['stack']['undo'].length).toEqual(0); contents = env.targetEditor.getContents(range.index, 1); - expect(contents.ops![0].insert.note).toBeDefined(); + expect((contents.ops![0].insert as any).note).toBeDefined(); env.dispose(); })); @@ -1457,7 +1459,7 @@ describe('EditorComponent', () => { // Keep track of operations triggered in Quill let textChangeOps: RichText.DeltaOperation[] = []; - env.targetEditor.on('text-change', (delta: DeltaStatic, _oldContents: DeltaStatic, _source: Sources) => { + env.targetEditor.on('text-change', (delta: Delta, _oldContents: Delta, _source: EmitterSource) => { if (delta.ops != null) { textChangeOps = textChangeOps.concat( delta.ops.map(op => { @@ -1650,7 +1652,7 @@ describe('EditorComponent', () => { env.setProjectUserConfig(); env.wait(); - const range: RangeStatic = env.component.target!.getSegmentRange('verse_1_3')!; + const range: Range = env.component.target!.getSegmentRange('verse_1_3')!; const contents = env.targetEditor.getContents(range.index, range.length); // The footnote starts after a note thread in the segment expect(contents.ops![1].insert).toEqual({ note: { caller: '*' } }); @@ -1689,7 +1691,7 @@ describe('EditorComponent', () => { // SUT env.wait(); - const range: RangeStatic = env.component.target!.getSegmentRange('verse_1_4')!; + const range: Range = env.component.target!.getSegmentRange('verse_1_4')!; const note4Position: number = env.getNoteThreadEditorPosition('dataid04'); const note4Doc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid04')!; const note4Anchor: TextAnchor = note4Doc.data!.position; @@ -1708,7 +1710,7 @@ describe('EditorComponent', () => { // SUT env.wait(); - const range: RangeStatic = env.component.target!.getSegmentRange('verse_1_3')!; + const range: Range = env.component.target!.getSegmentRange('verse_1_3')!; const note4Position: number = env.getNoteThreadEditorPosition('dataid04'); const note4Doc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid04')!; expect(note4Position).toEqual(range.index + 1); @@ -1932,7 +1934,7 @@ describe('EditorComponent', () => { // $target: chapter 1, |->$$verse 3<-|. env.targetEditor.setSelection(position, length, 'api'); env.deleteCharacters(); - const range: RangeStatic = env.component.target!.getSegmentRange('verse_1_3')!; + const range: Range = env.component.target!.getSegmentRange('verse_1_3')!; expect(env.getNoteThreadEditorPosition('dataid02')).toEqual(range.index); expect(env.getNoteThreadEditorPosition('dataid03')).toEqual(range.index + 1); expect(env.getNoteThreadEditorPosition('dataid04')).toEqual(range.index + 2); @@ -2162,7 +2164,7 @@ describe('EditorComponent', () => { // target: $chapter 1, verse 1. // move this ---- here ^ const deleteOps: DeltaOperation[] = [{ retain: deleteStart }, { delete: text.length }]; - const deleteDelta: DeltaStatic = new Delta(deleteOps); + const deleteDelta: Delta = new Delta(deleteOps); env.targetEditor.setSelection(deleteStart, text.length); // simulate a drag and drop operation, which include a delete and an insert operation env.targetEditor.updateContents(deleteDelta, 'user'); @@ -2170,7 +2172,7 @@ describe('EditorComponent', () => { env.fixture.detectChanges(); const insertStart: number = notePosition + 'ter 1, ver'.length; const insertOps: DeltaOperation[] = [{ retain: insertStart }, { insert: text }]; - const insertDelta: DeltaStatic = new Delta(insertOps); + const insertDelta: Delta = new Delta(insertOps); env.targetEditor.updateContents(insertDelta, 'user'); env.wait(); @@ -2195,14 +2197,14 @@ describe('EditorComponent', () => { env.routeWithParams({ projectId: 'project01', bookId: 'LUK' }); env.wait(); const textBeforeNote = 'Text in '; - let range: RangeStatic = env.component.target!.getSegmentRange('s_2')!; + let range: Range = env.component.target!.getSegmentRange('s_2')!; let notePosition: number = env.getNoteThreadEditorPosition('dataid06'); expect(range.index + textBeforeNote.length).toEqual(notePosition); const thread06Doc: NoteThreadDoc = env.getNoteThreadDoc('project01', 'dataid06'); let textAnchor: TextAnchor = thread06Doc.data!.position; expect(textAnchor).toEqual(origThread06Pos); - const verse2_3Range: RangeStatic = env.component.target!.getSegmentRange('verse_1_2-3')!; + const verse2_3Range: Range = env.component.target!.getSegmentRange('verse_1_2-3')!; env.targetEditor.setSelection(verse2_3Range.index + verse2_3Range.length); env.wait(); env.typeCharacters('T'); @@ -2289,7 +2291,7 @@ describe('EditorComponent', () => { // $ represents a note thread embed // target: $chap|ter 1, verse 1. const textDoc: TextDoc = env.getTextDoc(new TextDocId('project01', 40, 1)); - const insertDelta: DeltaStatic = new Delta(); + const insertDelta: Delta = new Delta(); (insertDelta as any).push({ retain: remoteEditTextPos } as DeltaOperation); (insertDelta as any).push({ insert: 'abc' } as DeltaOperation); // Simulate remote changes coming in @@ -2315,7 +2317,7 @@ describe('EditorComponent', () => { // $*targ|->et: cha<-|pter 1, $$verse 3. // ------- 7 characters get replaced locally by the text 'defgh' const selectionLength: number = 'et: cha'.length; - const insertDeleteDelta: DeltaStatic = new Delta(); + const insertDeleteDelta: Delta = new Delta(); (insertDeleteDelta as any).push({ retain: remoteEditTextPos } as DeltaOperation); (insertDeleteDelta as any).push({ insert: 'defgh' } as DeltaOperation); (insertDeleteDelta as any).push({ delete: selectionLength } as DeltaOperation); @@ -2330,7 +2332,7 @@ describe('EditorComponent', () => { remoteEditTextPos = env.getRemoteEditPosition(notePosition, remoteEditPositionAfterNote, noteCountBeforePosition); // $*targdefghpter |->1, $$v<-|erse 3. // ------ editor range deleted - const deleteDelta: DeltaStatic = new Delta(); + const deleteDelta: Delta = new Delta(); (deleteDelta as any).push({ retain: remoteEditTextPos } as DeltaOperation); // the remote edit deletes 4, but locally it is expanded to 6 to include the 2 note embeds (deleteDelta as any).push({ delete: 4 } as DeltaOperation); @@ -2418,7 +2420,7 @@ describe('EditorComponent', () => { // SUT 3 env.wait(); expect(env.component.target!.getSegmentText('verse_1_1')).toEqual('target: ' + insert + 'cdefhapter 1, verse 1.'); - const range: RangeStatic = env.component.target!.getSegmentRange('verse_1_1')!; + const range: Range = env.component.target!.getSegmentRange('verse_1_1')!; expect(env.getNoteThreadEditorPosition('dataid01')).toEqual(range.index + anchor.start); const contents = env.targetEditor.getContents(range.index, range.length); expect(contents.ops![0].insert).toEqual('target: ' + insert); @@ -2449,11 +2451,11 @@ describe('EditorComponent', () => { env.setProjectUserConfig(); env.wait(); - const range: RangeStatic = env.component.target!.getSegmentRange('verse_1_2')!; + const range: Range = env.component.target!.getSegmentRange('verse_1_2')!; env.targetEditor.setSelection(range.index); env.wait(); env.typeCharacters('t'); - let contents: DeltaStatic = env.targetEditor.getContents(range.index, 3); + let contents: Delta = env.targetEditor.getContents(range.index, 3); expect(contents.length()).toEqual(3); expect(contents.ops![0].insert).toEqual('t'); expect(contents.ops![1].insert['verse']).toBeDefined(); @@ -2462,7 +2464,7 @@ describe('EditorComponent', () => { env.backspace(); contents = env.targetEditor.getContents(range.index, 3); expect(contents.length()).toEqual(3); - expect(contents.ops![0].insert.blank).toBeDefined(); + expect((contents.ops![0].insert as any).blank).toBeDefined(); expect(contents.ops![1].insert['verse']).toBeDefined(); expect(contents.ops![2].insert['note-thread-embed']).toBeDefined(); env.dispose(); @@ -4925,9 +4927,10 @@ class TestEnvironment { } backspace(): void { - const selection = this.targetEditor.getSelection()!; - const delta = new Delta([{ retain: selection.index - 1 }, { delete: 1 }]); - this.targetEditor.updateContents(delta, 'user'); + const selection: Range = this.targetEditor.getSelection(); + const testUserEvent: UserEvent = userEvent.setup(); + const [leaf] = this.targetEditor.getLeaf(selection.index); + testUserEvent.type(leaf.parent.domNode, '{Backspace}'); // This will trigger the 'isBackspaceAllowed' check this.wait(); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.ts index 7ba518f6e1..d2bc624adc 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.ts @@ -31,7 +31,7 @@ import { } from '@sillsdev/machine'; import { Canon, VerseRef } from '@sillsdev/scripture'; import { isEqual } from 'lodash-es'; -import Quill, { DeltaStatic, RangeStatic } from 'quill'; +import Quill, { Bounds, Delta, Range } from 'quill'; import { Operation } from 'realtime-server/lib/esm/common/models/project-rights'; import { User } from 'realtime-server/lib/esm/common/models/user'; import { EditorTabGroupType } from 'realtime-server/lib/esm/scriptureforge/models/editor-tab'; @@ -88,12 +88,13 @@ import { filterNullish } from 'xforge-common/util/rxjs-util'; import { browserLinks, getLinkHTML, isBlink, issuesEmailTemplate, objectId } from 'xforge-common/utils'; import { XFValidators } from 'xforge-common/xfvalidators'; import { environment } from '../../../environments/environment'; +import { isString } from '../../../type-utils'; import { defaultNoteThreadIcon, NoteThreadDoc, NoteThreadIcon } from '../../core/models/note-thread-doc'; import { SFProjectDoc } from '../../core/models/sf-project-doc'; import { SFProjectProfileDoc } from '../../core/models/sf-project-profile-doc'; import { SF_DEFAULT_TRANSLATE_SHARE_ROLE } from '../../core/models/sf-project-role-info'; import { SFProjectUserConfigDoc } from '../../core/models/sf-project-user-config-doc'; -import { Delta, TextDocId } from '../../core/models/text-doc'; +import { TextDocId } from '../../core/models/text-doc'; import { Revision } from '../../core/paratext.service'; import { PermissionsService } from '../../core/permissions.service'; import { SFProjectService } from '../../core/sf-project.service'; @@ -103,6 +104,7 @@ import { BuildDto } from '../../machine-api/build-dto'; import { RemoteTranslationEngine } from '../../machine-api/remote-translation-engine'; import { TabFactoryService, TabGroup, TabMenuService, TabStateService } from '../../shared/sf-tab-group'; import { TabAddRequestService } from '../../shared/sf-tab-group/base-services/tab-add-request.service'; +import { getRetainCount } from '../../shared/text/quill-scripture'; import { Segment } from '../../shared/text/segment'; import { EmbedsByVerse, @@ -830,7 +832,7 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, async onTargetUpdated( segment?: Segment, - delta?: DeltaStatic, + delta?: Delta, prevSegment?: Segment, preDeltaAffectedEmbeds?: EmbedsByVerse[], isLocalUpdate?: boolean @@ -889,17 +891,19 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, this.source.setSegment(this.target.segmentRef); } - if (delta != null && delta.ops != null) { + if (delta?.ops != null) { + const retainCount: number = getRetainCount(delta.ops[0]); + const insertText: string | undefined = isString(delta.ops[1]?.insert) ? delta.ops[1].insert : undefined; // insert a space if the user just inserted a suggestion and started typing if ( delta.ops.length === 2 && - delta.ops[0].retain === this.insertSuggestionEnd && - delta.ops[1].insert != null && - delta.ops[1].insert.length > 0 && - !PUNCT_SPACE_REGEX.test(delta.ops[1].insert) + retainCount === this.insertSuggestionEnd && + insertText != null && + insertText.length > 0 && + !PUNCT_SPACE_REGEX.test(insertText) ) { this.target.editor.insertText(this.insertSuggestionEnd, ' ', 'user'); - const selectIndex = this.insertSuggestionEnd + delta.ops[1].insert.length + 1; + const selectIndex = this.insertSuggestionEnd + insertText.length + 1; this.insertSuggestionEnd = -1; this.target.editor.setSelection(selectIndex, 0, 'user'); } @@ -968,11 +972,11 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, switch (textType) { case 'source': this.sourceLoaded = true; - this.sourceScrollContainer = this.source?.editor?.scrollingContainer; + this.sourceScrollContainer = this.source?.editor?.root; break; case 'target': this.targetLoaded = true; - this.targetScrollContainer = this.target?.editor?.scrollingContainer; + this.targetScrollContainer = this.target?.editor?.root; this.toggleNoteThreadVerseRefs$.next(); this.shouldNoteThreadsRespondToEdits = true; @@ -1829,7 +1833,7 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, } } - private skipInitialWhitespace(editor: Quill, range: RangeStatic): RangeStatic { + private skipInitialWhitespace(editor: Quill, range: Range): Range { let i: number; for (i = range.index; i < range.index + range.length; i++) { const ch = editor.getText(i, 1); @@ -1990,7 +1994,7 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, component: ComponentType, dialogConfig: MatDialogConfig ): MatDialogRef { - const selection: RangeStatic | null | undefined = this.target?.editor?.getSelection(); + const selection: Range | null | undefined = this.target?.editor?.getSelection(); const targetScrollTop: number | undefined = this.targetScrollContainer?.scrollTop; const dialogRef: MatDialogRef = this.dialogService.openMatDialog(component, dialogConfig); @@ -2000,7 +2004,7 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, const subscription: Subscription = dialogRef.afterClosed().subscribe(() => { if (this.target?.editor != null && this.dialogService.openDialogCount === 0) { - const currentSelection: RangeStatic | null | undefined = this.target.editor.getSelection(); + const currentSelection: Range | null | undefined = this.target.editor.getSelection(); if (currentSelection?.index !== selection.index) { this.target.editor.setSelection(selection.index, 0, 'user'); @@ -2051,7 +2055,7 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, } /** Update the text anchors for the note threads in the current segment. */ - private async updateVerseNoteThreadAnchors(affectedEmbeds: EmbedsByVerse[], delta: DeltaStatic): Promise { + private async updateVerseNoteThreadAnchors(affectedEmbeds: EmbedsByVerse[], delta: Delta): Promise { if (this.target == null || this.noteThreadQuery == null || this.noteThreadQuery.docs.length < 1) { return; } @@ -2068,7 +2072,7 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, ); const reinsertedNoteIds: string[] = []; reinsertedNoteEmbeds.forEach(n => { - if (n.attributes != null && n.attributes['threadid'] != null) { + if (isString(n.attributes?.['threadid'])) { reinsertedNoteIds.push(n.attributes['threadid']); } }); @@ -2142,7 +2146,7 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, private positionInsertNoteFab(): void { if (this.insertNoteFab == null || this.target?.editor == null || this.addingMobileNote) return; // getSelection can steal the focus, so we should not call this if the add mobile note bottom sheet is open - const selection: RangeStatic | null | undefined = this.target.editor.getSelection(); + const selection: Range | null | undefined = this.target.editor.getSelection(); if (selection != null) { this.insertNoteFab.nativeElement.style.top = `${this.target.selectionBoundsTop}px`; this.insertNoteFab.nativeElement.style.marginTop = `-${this.target.scrollPosition}px`; @@ -2245,8 +2249,8 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, oldTextAnchor: TextAnchor, oldVerseEmbedPositions: Map, noteIndex: number, - verseRange: RangeStatic, - delta: DeltaStatic + verseRange: Range, + delta: Delta ): TextAnchor | undefined { if (oldTextAnchor.start === 0 && oldTextAnchor.length === 0) { return oldTextAnchor; @@ -2275,8 +2279,8 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, private getAnchorChanges( embedPosition: number, noteAnchorEndIndex: number, - verseRange: RangeStatic, - delta: DeltaStatic, + verseRange: Range, + delta: Delta, embedPositions: Set ): [number, number] { let startChange: number = 0; @@ -2289,9 +2293,9 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, for (const op of delta.ops) { const insertOp: any = op.insert; const deleteOp: number | undefined = op.delete; - const retainOp: number | undefined = op.retain; - if (retainOp != null) { - curIndex += retainOp; + const retainCount: number | undefined = getRetainCount(op); + if (retainCount != null) { + curIndex += retainCount; continue; } @@ -2339,7 +2343,7 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, } /** Gets the first edit position within the given range. */ - private getEditPositionWithinRange(range: RangeStatic, delta: DeltaStatic): number | undefined { + private getEditPositionWithinRange(range: Range, delta: Delta): number | undefined { if (delta.ops == null) { return undefined; } @@ -2352,8 +2356,8 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, return curIndex; } - const retainOp: number | undefined = op.retain; - curIndex += retainOp == null ? 0 : retainOp; + const retainCount: number | undefined = getRetainCount(op) ?? 0; + curIndex += retainCount; } return undefined; } @@ -2463,11 +2467,11 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, return; } - const targetRange: RangeStatic = this.target.segment.range; - const targetSelectionBounds: DOMRect = this.target.editor.selection.getBounds(targetRange.index); + const targetRange: Range = this.target.segment.range; + const targetSelectionBounds: DOMRect | Bounds = this.target.editor.selection.getBounds(targetRange.index); - const sourceRange: RangeStatic = this.source.segment.range; - const sourceSelectionBounds: DOMRect = this.source.editor.selection.getBounds( + const sourceRange: Range = this.source.segment.range; + const sourceSelectionBounds: DOMRect | Bounds = this.source.editor.selection.getBounds( sourceRange.index, sourceRange.length ); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/suggestions.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/suggestions.component.ts index 7508e6a8d1..c3bbfd7c15 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/suggestions.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/suggestions.component.ts @@ -226,7 +226,7 @@ export class SuggestionsComponent extends SubscriptionDisposable implements OnDe // root.scrollTop should be 0 if scrollContainer !== root this.top = reference.bottom + this.editor.root.scrollTop + 5; const suggestionBounds = this.root.getBoundingClientRect(); - const editorBounds = this.editor.scrollingContainer.getBoundingClientRect(); + const editorBounds = this.editor.root.getBoundingClientRect(); let newLeft: number | undefined = reference.left + 1; let newRight: number | undefined = editorBounds.width - reference.left - 1; @@ -244,7 +244,7 @@ export class SuggestionsComponent extends SubscriptionDisposable implements OnDe const marginTop = -this.editor.root.scrollTop; const offsetTop = marginTop + this.top; const offsetBottom = offsetTop + this.root.clientHeight - 10; - if (offsetTop < 0 || offsetBottom > this.editor.scrollingContainer.clientHeight) { + if (offsetTop < 0 || offsetBottom > this.editor.root.clientHeight) { if (this.root.style.visibility !== 'hidden') { this.root.style.visibility = 'hidden'; this.root.style.marginTop = -this.top + 'px'; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/training-progress/training-progress.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/training-progress/training-progress.component.spec.ts index 5610365d19..bddfdc0e95 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/training-progress/training-progress.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/training-progress/training-progress.component.spec.ts @@ -2,6 +2,7 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { provideHttpClientTesting } from '@angular/common/http/testing'; import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; import { ProgressStatus } from '@sillsdev/machine'; +import { Delta } from 'quill'; import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; import { SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role'; import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; @@ -15,7 +16,7 @@ import { UICommonModule } from 'xforge-common/ui-common.module'; import { UserService } from 'xforge-common/user.service'; import { SFProjectProfileDoc } from '../../core/models/sf-project-profile-doc'; import { SF_TYPE_REGISTRY } from '../../core/models/sf-type-registry'; -import { Delta, TextDoc, TextDocId } from '../../core/models/text-doc'; +import { TextDoc, TextDocId } from '../../core/models/text-doc'; import { SFProjectService } from '../../core/sf-project.service'; import { TranslationEngineService } from '../../core/translation-engine.service'; import { RemoteTranslationEngine } from '../../machine-api/remote-translation-engine'; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/translate-overview/translate-overview.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/translate-overview/translate-overview.component.spec.ts index 452178fc85..f7c441be33 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/translate-overview/translate-overview.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/translate-overview/translate-overview.component.spec.ts @@ -6,6 +6,7 @@ import { By } from '@angular/platform-browser'; import { ActivatedRoute, Params, RouterModule } from '@angular/router'; import { ProgressStatus } from '@sillsdev/machine'; import { CookieService } from 'ngx-cookie-service'; +import { Delta } from 'quill'; import { User } from 'realtime-server/lib/esm/common/models/user'; import { createTestUser } from 'realtime-server/lib/esm/common/models/user-test-data'; import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; @@ -31,7 +32,7 @@ import { UICommonModule } from 'xforge-common/ui-common.module'; import { UserService } from 'xforge-common/user.service'; import { SFProjectProfileDoc } from '../../core/models/sf-project-profile-doc'; import { SF_TYPE_REGISTRY } from '../../core/models/sf-type-registry'; -import { Delta, TextDoc, TextDocId } from '../../core/models/text-doc'; +import { TextDoc, TextDocId } from '../../core/models/text-doc'; import { PermissionsService } from '../../core/permissions.service'; import { SFProjectService } from '../../core/sf-project.service'; import { TranslationEngineService } from '../../core/translation-engine.service'; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/typings/quill.d.ts b/src/SIL.XForge.Scripture/ClientApp/src/typings/quill.d.ts deleted file mode 100644 index 087858656f..0000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/typings/quill.d.ts +++ /dev/null @@ -1,107 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import 'quill'; - -/* SystemJS module definition */ -declare var module: NodeModule; -interface NodeModule { - id: string; -} - -declare module 'quill' { - export interface HistoryStatic { - clear(): void; - undo(): void; - redo(): void; - cutoff(): void; - } - - export interface Quill { - theme: Theme; - container: Element; - scrollingContainer: Element; - selection: Selection; - history: HistoryStatic; - - isEnabled(): boolean; - setSelection(index: number, source?: Sources): void; - } - - export interface Selection { - getBounds(index: number, length?: number): DOMRect; - update(sources: Sources): void; - } - - export class Theme { - quill: Quill; - options: QuillOptionsStatic; - constructor(quill: Quill, options: QuillOptionsStatic); - } - - export class SnowTheme extends Theme { - pickers: Picker[]; - extendToolbar(toolbar: any): void; - } - - export class Tooltip { - quill: Quill; - boundsContainer: HTMLElement; - root: HTMLElement; - constructor(quill: Quill, boundsContainer: HTMLElement); - hide(): void; - position(reference: any): number; - show(): void; - } - - export class Module { - quill: Quill; - options: QuillOptionsStatic; - constructor(quill: Quill, options: QuillOptionsStatic); - } - - export class Toolbar extends Module { - controls: Array<[string, HTMLElement]>; - handlers: { [format: string]: (value: any) => void }; - - attach(input: HTMLElement): void; - update(range: RangeStatic): void; - } - - export class Clipboard extends Module implements ClipboardStatic { - container: HTMLElement; - - addMatcher(selectorOrNodeType: string | number, callback: (node: any, delta: DeltaStatic) => DeltaStatic): void; - dangerouslyPasteHTML(html: string, source?: Sources): void; - dangerouslyPasteHTML(index: number, html: string, source?: Sources): void; - dangerouslyPasteHTML(index: any, html?: any, source?: any): void; - onPaste(e: ClipboardEvent): void; - convert(html?: string): DeltaStatic; - } - - export interface HistoryDelta { - undo: DeltaStatic; - redo: DeltaStatic; - } - - export interface HistoryStack { - undo: HistoryDelta[]; - redo: HistoryDelta[]; - } - - export type HistoryStackType = Extract; - - export class History extends Module implements HistoryStatic { - lastRecorded: number; - ignoreChange: boolean; - stack: HistoryStack; - - clear(): void; - undo(): void; - redo(): void; - cutoff(): void; - change(source: HistoryStackType, dest: HistoryStackType): void; - } - - export class Picker { - update(): void; - } -} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/typings/rich-text.d.ts b/src/SIL.XForge.Scripture/ClientApp/src/typings/rich-text.d.ts index c7f20b4f15..f8ed6d8e01 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/typings/rich-text.d.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/typings/rich-text.d.ts @@ -2,5 +2,9 @@ declare module 'rich-text' { import { OTType } from 'sharedb/lib/client'; export let type: OTType; - export { Delta, DeltaOperation } from 'quill'; + export { Op as DeltaOperation } from 'quill'; + + export interface StringMap { + [key: string]: any; + } }