From 78ee139b48e4555b5405cd34eaaae99784f58790 Mon Sep 17 00:00:00 2001 From: Varun Sethu Date: Mon, 31 Oct 2022 23:20:17 +1100 Subject: [PATCH 1/4] Added stage 1 of OT frontend changes - operation capture --- frontend/package-lock.json | 343 ++++++++++++++++++ frontend/package.json | 4 + .../packages/editor/api/OTClient/README.md | 3 + .../packages/editor/api/OTClient/client.ts | 106 ++++++ .../packages/editor/api/OTClient/operation.ts | 168 +++++++++ .../editor/api/OTClient/operationQueue.ts | 60 +++ .../src/packages/editor/api/OTClient/util.ts | 9 + .../src/packages/editor/api/cmsFS/volumes.ts | 13 + .../src/packages/editor/componentFactory.tsx | 71 ++++ .../editor/components/EditorBlock.tsx | 10 +- .../editor/components/HeadingBlock.tsx | 16 +- frontend/src/packages/editor/index.tsx | 149 +++----- .../src/packages/editor/operationManager.tsx | 33 ++ frontend/src/packages/editor/types.ts | 8 +- frontend/tsconfig.json | 2 +- 15 files changed, 879 insertions(+), 116 deletions(-) create mode 100644 frontend/src/packages/editor/api/OTClient/README.md create mode 100644 frontend/src/packages/editor/api/OTClient/client.ts create mode 100644 frontend/src/packages/editor/api/OTClient/operation.ts create mode 100644 frontend/src/packages/editor/api/OTClient/operationQueue.ts create mode 100644 frontend/src/packages/editor/api/OTClient/util.ts create mode 100644 frontend/src/packages/editor/api/cmsFS/volumes.ts create mode 100644 frontend/src/packages/editor/componentFactory.tsx create mode 100644 frontend/src/packages/editor/operationManager.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d05bacb3..eb3ac3d7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,8 @@ "@mui/material": "5.8.5", "@mui/styles": "5.8.4", "@reduxjs/toolkit": "1.8.4", + "@types/socket.io": "^3.0.2", + "@types/socket.io-client": "^3.0.0", "react": "17.0.2", "react-dom": "17.0.2", "react-icons": "4.3.1", @@ -25,6 +27,8 @@ "redux-saga": "1.1.3", "slate": "0.78.0", "slate-react": "0.79.0", + "socket.io": "^4.5.3", + "socket.io-client": "^4.5.3", "styled-components": "5.3.5", "uuid": "8.3.2", "web-vitals": "1.1.2" @@ -4552,6 +4556,11 @@ "@sinonjs/commons": "^1.7.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", + "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==" + }, "node_modules/@storybook/addon-actions": { "version": "6.5.9", "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-6.5.9.tgz", @@ -10611,6 +10620,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==" + }, + "node_modules/@types/cors": { + "version": "2.8.12", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz", + "integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==" + }, "node_modules/@types/draft-js": { "version": "0.11.9", "resolved": "https://registry.npmjs.org/@types/draft-js/-/draft-js-0.11.9.tgz", @@ -10987,6 +11006,24 @@ "@types/node": "*" } }, + "node_modules/@types/socket.io": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/socket.io/-/socket.io-3.0.2.tgz", + "integrity": "sha512-pu0sN9m5VjCxBZVK8hW37ZcMe8rjn4HHggBN5CbaRTvFwv5jOmuIRZEuddsBPa9Th0ts0SIo3Niukq+95cMBbQ==", + "deprecated": "This is a stub types definition. socket.io provides its own type definitions, so you do not need this installed.", + "dependencies": { + "socket.io": "*" + } + }, + "node_modules/@types/socket.io-client": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/socket.io-client/-/socket.io-client-3.0.0.tgz", + "integrity": "sha512-s+IPvFoEIjKA3RdJz/Z2dGR4gLgysKi8owcnrVwNjgvc01Lk68LJDDsG2GRqegFITcxmvCMYM7bhMpwEMlHmDg==", + "deprecated": "This is a stub types definition. socket.io-client provides its own type definitions, so you do not need this installed.", + "dependencies": { + "socket.io-client": "*" + } + }, "node_modules/@types/sockjs": { "version": "0.3.33", "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.33.tgz", @@ -13058,6 +13095,14 @@ } ] }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/batch": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", @@ -14506,6 +14551,18 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/cosmiconfig": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", @@ -16573,6 +16630,94 @@ "objectorarray": "^1.0.5" } }, + "node_modules/engine.io": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.2.0.tgz", + "integrity": "sha512-4KzwW3F3bk+KlzSOY57fj/Jx6LyRQ1nbcyIadehl+AnXjKT7gDO0ORdRi/84ixvMKTym6ZKuxvbzN62HDDU1Lg==", + "dependencies": { + "@types/cookie": "^0.4.1", + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.4.1", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.0.3", + "ws": "~8.2.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io-client": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.2.3.tgz", + "integrity": "sha512-aXPtgF1JS3RuuKcpSrBtimSjYvrbhKW9froICH4s0F3XQWLxsKNxqzG39nnvQZQnva4CMvUK63T7shevxRyYHw==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.0.3", + "ws": "~8.2.3", + "xmlhttprequest-ssl": "~2.0.0" + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", + "integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.4.tgz", + "integrity": "sha512-+nVFp+5z1E3HcToEnO7ZIj3g+3k9389DvWtvJZz0T6/eOCPIyyxehFcedoYrZQrp0LgQbD9pPXhpMBKMd5QURg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/ws": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", + "integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/enhanced-resolve": { "version": "5.10.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz", @@ -30993,6 +31138,53 @@ "urix": "^0.1.0" } }, + "node_modules/socket.io": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.5.3.tgz", + "integrity": "sha512-zdpnnKU+H6mOp7nYRXH4GNv1ux6HL6+lHL8g7Ds7Lj8CkdK1jJK/dlwsKDculbyOHifcJ0Pr/yeXnZQ5GeFrcg==", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "debug": "~4.3.2", + "engine.io": "~6.2.0", + "socket.io-adapter": "~2.4.0", + "socket.io-parser": "~4.2.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.4.0.tgz", + "integrity": "sha512-W4N+o69rkMEGVuk2D/cvca3uYsvGlMwsySWV447y99gUPghxq42BxqLNMndb+a1mm/5/7NeXVQS7RLa2XyXvYg==" + }, + "node_modules/socket.io-client": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.5.3.tgz", + "integrity": "sha512-I/hqDYpQ6JKwtJOf5ikM+Qz+YujZPMEl6qBLhxiP0nX+TfXKhW4KZZG8lamrD6Y5ngjmYHreESVasVCgi5Kl3A==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.2.3", + "socket.io-parser": "~4.2.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.1.tgz", + "integrity": "sha512-V4GrkLy+HeF1F/en3SpUaM+7XxYXpuMUWLGde1kSSh5nQMN4hLrbPIkD+otwh6q9R6NOQBN4AMaOZ2zVjui82g==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/sockjs": { "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", @@ -34773,6 +34965,14 @@ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -37988,6 +38188,11 @@ "@sinonjs/commons": "^1.7.0" } }, + "@socket.io/component-emitter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", + "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==" + }, "@storybook/addon-actions": { "version": "6.5.9", "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-6.5.9.tgz", @@ -42548,6 +42753,16 @@ "@types/node": "*" } }, + "@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==" + }, + "@types/cors": { + "version": "2.8.12", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz", + "integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==" + }, "@types/draft-js": { "version": "0.11.9", "resolved": "https://registry.npmjs.org/@types/draft-js/-/draft-js-0.11.9.tgz", @@ -42917,6 +43132,22 @@ "@types/node": "*" } }, + "@types/socket.io": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/socket.io/-/socket.io-3.0.2.tgz", + "integrity": "sha512-pu0sN9m5VjCxBZVK8hW37ZcMe8rjn4HHggBN5CbaRTvFwv5jOmuIRZEuddsBPa9Th0ts0SIo3Niukq+95cMBbQ==", + "requires": { + "socket.io": "*" + } + }, + "@types/socket.io-client": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/socket.io-client/-/socket.io-client-3.0.0.tgz", + "integrity": "sha512-s+IPvFoEIjKA3RdJz/Z2dGR4gLgysKi8owcnrVwNjgvc01Lk68LJDDsG2GRqegFITcxmvCMYM7bhMpwEMlHmDg==", + "requires": { + "socket.io-client": "*" + } + }, "@types/sockjs": { "version": "0.3.33", "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.33.tgz", @@ -44527,6 +44758,11 @@ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "dev": true }, + "base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==" + }, "batch": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", @@ -45664,6 +45900,15 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, + "cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, "cosmiconfig": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", @@ -47201,6 +47446,61 @@ "objectorarray": "^1.0.5" } }, + "engine.io": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.2.0.tgz", + "integrity": "sha512-4KzwW3F3bk+KlzSOY57fj/Jx6LyRQ1nbcyIadehl+AnXjKT7gDO0ORdRi/84ixvMKTym6ZKuxvbzN62HDDU1Lg==", + "requires": { + "@types/cookie": "^0.4.1", + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.4.1", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.0.3", + "ws": "~8.2.3" + }, + "dependencies": { + "cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==" + }, + "ws": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", + "integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==", + "requires": {} + } + } + }, + "engine.io-client": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.2.3.tgz", + "integrity": "sha512-aXPtgF1JS3RuuKcpSrBtimSjYvrbhKW9froICH4s0F3XQWLxsKNxqzG39nnvQZQnva4CMvUK63T7shevxRyYHw==", + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.0.3", + "ws": "~8.2.3", + "xmlhttprequest-ssl": "~2.0.0" + }, + "dependencies": { + "ws": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", + "integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==", + "requires": {} + } + } + }, + "engine.io-parser": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.4.tgz", + "integrity": "sha512-+nVFp+5z1E3HcToEnO7ZIj3g+3k9389DvWtvJZz0T6/eOCPIyyxehFcedoYrZQrp0LgQbD9pPXhpMBKMd5QURg==" + }, "enhanced-resolve": { "version": "5.10.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz", @@ -57853,6 +58153,44 @@ } } }, + "socket.io": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.5.3.tgz", + "integrity": "sha512-zdpnnKU+H6mOp7nYRXH4GNv1ux6HL6+lHL8g7Ds7Lj8CkdK1jJK/dlwsKDculbyOHifcJ0Pr/yeXnZQ5GeFrcg==", + "requires": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "debug": "~4.3.2", + "engine.io": "~6.2.0", + "socket.io-adapter": "~2.4.0", + "socket.io-parser": "~4.2.0" + } + }, + "socket.io-adapter": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.4.0.tgz", + "integrity": "sha512-W4N+o69rkMEGVuk2D/cvca3uYsvGlMwsySWV447y99gUPghxq42BxqLNMndb+a1mm/5/7NeXVQS7RLa2XyXvYg==" + }, + "socket.io-client": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.5.3.tgz", + "integrity": "sha512-I/hqDYpQ6JKwtJOf5ikM+Qz+YujZPMEl6qBLhxiP0nX+TfXKhW4KZZG8lamrD6Y5ngjmYHreESVasVCgi5Kl3A==", + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.2.3", + "socket.io-parser": "~4.2.0" + } + }, + "socket.io-parser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.1.tgz", + "integrity": "sha512-V4GrkLy+HeF1F/en3SpUaM+7XxYXpuMUWLGde1kSSh5nQMN4hLrbPIkD+otwh6q9R6NOQBN4AMaOZ2zVjui82g==", + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + } + }, "sockjs": { "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", @@ -60806,6 +61144,11 @@ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" }, + "xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==" + }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 107d65d1..99a72094 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,8 @@ "@mui/material": "5.8.5", "@mui/styles": "5.8.4", "@reduxjs/toolkit": "1.8.4", + "@types/socket.io": "^3.0.2", + "@types/socket.io-client": "^3.0.0", "react": "17.0.2", "react-dom": "17.0.2", "react-icons": "4.3.1", @@ -20,6 +22,8 @@ "redux-saga": "1.1.3", "slate": "0.78.0", "slate-react": "0.79.0", + "socket.io": "^4.5.3", + "socket.io-client": "^4.5.3", "styled-components": "5.3.5", "uuid": "8.3.2", "web-vitals": "1.1.2" diff --git a/frontend/src/packages/editor/api/OTClient/README.md b/frontend/src/packages/editor/api/OTClient/README.md new file mode 100644 index 00000000..71b7f563 --- /dev/null +++ b/frontend/src/packages/editor/api/OTClient/README.md @@ -0,0 +1,3 @@ +# CMS OT Client + +TODO: there really needs to be a better way of doing this, currently this code is copied directly from the backend folder. In the future there should maybe be some shared folder between frontend and backend JUST for stuff like API clients? \ No newline at end of file diff --git a/frontend/src/packages/editor/api/OTClient/client.ts b/frontend/src/packages/editor/api/OTClient/client.ts new file mode 100644 index 00000000..fca8cd0d --- /dev/null +++ b/frontend/src/packages/editor/api/OTClient/client.ts @@ -0,0 +1,106 @@ +/** + * Client server implementation + */ + +import { io, Socket } from "socket.io-client"; +import { CMSOperation } from "./operation"; +import { OperationQueue } from "./operationQueue"; +import { bind } from "./util"; + +const ACK_TIMEOUT_DURATION = 10_000; + +/* + The Client-Server protocol + - The general outline of our client-server protocol is as follows: + - Client wants to send an operation (it applies it locally) + - If there are any operations in the op buffer it pushes it to the end + - If there aren't it sends it directly to the server + + - The client then awaits for an acknowledgment + - While it waits of an acknowledgement it queues everything in the buffer + - All incoming operations from the server are transformed against buffer operations (As they haven't been applied yet) + - When at acknowledgement is received the client then sends the next queued operation to the server +*/ + +export default class Client { + // TODO: Handle destruction / closing of the websocket + constructor(opCallback: (op: CMSOperation) => void) { + this.socket = io(`ws://localhost:8080/edit?document=${document}`); + + this.socket.on("connect", this.handleConnection); + this.socket.on("ack", this.handleAck); + this.socket.on("op", this.handleOperation(opCallback)); + } + + /** + * Handles an incoming acknowledgement operation + */ + private handleAck = () => { + clearTimeout(this.timeoutID); + this.pendingAcknowledgement = false; + + // dequeue the current operation and send a new one if required + this.queuedOperations.dequeueOperation(); + bind((op) => this.sendToServer(op), this.queuedOperations.peekHead()); + }; + + /** + * Handles an incoming operation from the server + */ + private handleOperation = + (opCallback: (op: CMSOperation) => void) => (operation: CMSOperation) => { + const transformedOp = + this.queuedOperations.applyAndTransformIncomingOperation(operation); + opCallback(transformedOp); + + this.appliedOperations += 1; + }; + + /** + * Handles the even when the connection opens + */ + private handleConnection = () => { + console.log(`Socket ${this.socket.id} connected: ${this.socket.connected}`); + }; + + /** + * Send an operation from client to centralised server through websocket + * + * @param operation the operation the client wants to send + */ + public pushOperation = (operation: CMSOperation) => { + // Note that if there aren't any pending acknowledgements then the operation queue will be empty + this.queuedOperations.enqueueOperation(operation); + + if (!this.pendingAcknowledgement) { + this.sendToServer(operation); + } + }; + + /** + * Pushes an operation to the server + */ + private sendToServer = (operation: CMSOperation) => { + this.pendingAcknowledgement = true; + + this.socket.send( + JSON.stringify({ operation, appliedOperations: this.appliedOperations }) + ); + this.timeoutID = setTimeout( + () => { + throw Error(`Did not receive ACK after ${ACK_TIMEOUT_DURATION} ms!`); + }, + ACK_TIMEOUT_DURATION, + "finish" + ); + }; + + private socket: Socket; + + private queuedOperations: OperationQueue = new OperationQueue(); + private pendingAcknowledgement = false; + private appliedOperations = 0; + + // distinct types between node and the browser confuses typescript + private timeoutID: any = 0; +} diff --git a/frontend/src/packages/editor/api/OTClient/operation.ts b/frontend/src/packages/editor/api/OTClient/operation.ts new file mode 100644 index 00000000..6b12df6a --- /dev/null +++ b/frontend/src/packages/editor/api/OTClient/operation.ts @@ -0,0 +1,168 @@ +// Represents atomic operations that can be applied to a piece of data of a specific type +// TODO: in the future update object operation to strictly contain CMS operation data +type stringOperation = { rangeStart: number; rangeEnd: number, newValue: string }; +type integerOperation = { newValue: number }; +type booleanOperation = { newValue: boolean }; +// eslint-disable-next-line @typescript-eslint/ban-types +type objectOperation = { newValue: object }; +// eslint-disable-next-line @typescript-eslint/ban-types +type arrayOperation = { newValue: object }; +type noop = Record; + +// atomicOperation is a single operation that can be applied in our system +type atomicOperation = +| { "$type": "stringOperation", stringOperation: stringOperation } +| { "$type": "integerOperation", integerOperation: integerOperation } +| { "$type": "booleanOperation", booleanOperation: booleanOperation } +| { "$type": "objectOperation", objectOperation: objectOperation } +| { "$type": "arrayOperation", arrayOperation: arrayOperation } +| { "$type": "noop", noop: noop} + +// operation is the atomic operation that is sent between clients and servers +export type CMSOperation = { + Path: number[], + OperationType: "insert" | "delete", + + IsNoOp: boolean + Operation: atomicOperation +} + +export const noop: CMSOperation = { + Path: [], + OperationType: "insert", + IsNoOp: true, + Operation: { + "$type": "noop", + noop: {} + } +}; + +// Actual OT transformation functions +export const transform = ( + a: CMSOperation, + b: CMSOperation + ): [CMSOperation, CMSOperation] => { + const transformedPaths = transformPaths(a, b); + [a.Path, b.Path] = transformedPaths; + + return [normalise(a), normalise(b)]; + }; + + /** + * Takes in two operations and transforms them accordingly, note that it only + * returns the updated paths + */ + const transformPaths = (a: CMSOperation, b: CMSOperation): [number[], number[]] => { + const tp = transformationPoint(a.Path, b.Path); + if (!effectIndependent(a.Path, b.Path, tp)) { + switch (true) { + case a.OperationType === "insert" && b.OperationType === "insert": + return transformInserts(a.Path, b.Path, tp); + case a.OperationType === "delete" && b.OperationType === "delete": + return transformDeletes(a.Path, b.Path, tp); + case a.OperationType === "insert" && b.OperationType === "delete": + return transformInsertDelete(a.Path, b.Path, tp); + default: { + const result = transformInsertDelete(b.Path, a.Path, tp); + result.reverse(); + return result; + } + } + } + + return [a.Path, b.Path]; + }; + + /** + * Takes 2 paths and their transformation point and transforms them as if they + * were insertion functions + */ + const transformInserts = ( + a: number[], + b: number[], + tp: number + ): [number[], number[]] => { + switch (true) { + case a[tp] > b[tp]: + return [update(a, tp, 1), b]; + case a[tp] < b[tp]: + return [a, update(b, tp, 1)]; + default: + return a.length > b.length + ? [update(a, tp, 1), b] + : (a.length < b.length + ? [a, update(b, tp, 1)] + : [a, b]); + } + }; + + /** + * Takes 2 paths and transforms them as if they were deletion operations + */ + const transformDeletes = ( + a: number[], + b: number[], + tp: number + ): [number[], number[]] => { + switch (true) { + case a[tp] > b[tp]: + return [update(a, tp, -1), b]; + case a[tp] < b[tp]: + return [a, update(b, tp, -1)]; + default: + return a.length > b.length + ? [[], b] + : (a.length < b.length + ? [a, []] + : [[], []]); + } + }; + + /** + * Takes an insertion operation and a deletion operation and transforms them + */ + const transformInsertDelete = ( + insertOp: number[], + deleteOp: number[], + tp: number + ): [number[], number[]] => { + switch (true) { + case insertOp[tp] > deleteOp[tp]: + return [update(insertOp, tp, -1), deleteOp]; + case insertOp[tp] < deleteOp[tp]: + return [insertOp, update(deleteOp, tp, 1)]; + default: + return insertOp.length > deleteOp.length + ? [[], deleteOp] + : [insertOp, update(deleteOp, tp, 1)]; + } + }; + + /** + * Updates a specific index in a path + */ + const update = (pos: number[], toChange: number, change: number) => { + pos[toChange] += change; + return pos; + }; + + /** + * Takes in two paths and computes their transformation point + */ + const transformationPoint = (a: number[], b: number[]): number => + [...Array(Math.min(a.length, b.length)).keys()].find( + (i) => a[i] != b[i] + ) ?? Math.min(a.length, b.length); + + /** + * Takes two paths and determines if their effect is independent or not + */ + const effectIndependent = (a: number[], b: number[], tp: number): boolean => + (a.length > tp + 1 && b.length > tp + 1) || + (a[tp] > b[tp] && a.length < b.length) || + (a[tp] < b[tp] && a.length > b.length); + + /** + * Normalise turns an empty operation into a noop + */ + const normalise = (a: CMSOperation): CMSOperation => (a.Path.length === 0 ? noop : a); \ No newline at end of file diff --git a/frontend/src/packages/editor/api/OTClient/operationQueue.ts b/frontend/src/packages/editor/api/OTClient/operationQueue.ts new file mode 100644 index 00000000..70c6f2c8 --- /dev/null +++ b/frontend/src/packages/editor/api/OTClient/operationQueue.ts @@ -0,0 +1,60 @@ +import { CMSOperation, transform } from "./operation"; + +/** + * OperationQueue is a simple data structure of the maintenance of outgoing + * operations, new operations are pushed to this queue and when and incoming + * operation from the server applies that operation is transformed against all + * elements of this queue + */ +export class OperationQueue { + + /** + * Push an operation to the end of the operation queue + * + * @param operation - the new operation to add + * @returns the new length of the queue + */ + public enqueueOperation = (operation: CMSOperation): number => + this.operationQueue.push(operation); + + /** + * Takes an incoming operation from the server and applies it to all + * elements of the queue + * + * @param serverOp - the incoming operation from the server + * @returns serverOp transformed against all operations in the operation queue + */ + public applyAndTransformIncomingOperation = ( + serverOp: CMSOperation + ): CMSOperation => { + const { newQueue, newOp } = this.operationQueue.reduce( + (prevSet, op) => { + const newOp = transform(op, prevSet.newOp); + return { newQueue: prevSet.newQueue.concat(newOp), newOp: newOp[1] }; + }, + { newQueue: [] as CMSOperation[], newOp: serverOp } + ); + + this.operationQueue = newQueue; + return newOp; + }; + + /** + * @returns if are any operations queued + */ + public isEmpty = (): boolean => this.operationQueue.length === 0; + + /** + * @returns the operation at the head of the operation queue and removes it + */ + public dequeueOperation = (): CMSOperation | undefined => + this.operationQueue.shift(); + + /** + * @returns the operation at the head of the operation queue + */ + public peekHead = (): CMSOperation | undefined => this.operationQueue[0]; + + // operationQueue is our internal operation queue + private operationQueue = [] as CMSOperation[]; +} diff --git a/frontend/src/packages/editor/api/OTClient/util.ts b/frontend/src/packages/editor/api/OTClient/util.ts new file mode 100644 index 00000000..7d228578 --- /dev/null +++ b/frontend/src/packages/editor/api/OTClient/util.ts @@ -0,0 +1,9 @@ +/** + * Bind operation in functional programming + * + * @param f - the function to apply on the value + * @param y - the value + * @returns the return value of the function with y passed in if y is not undefined else undefined + */ +export const bind = (f: (x: T) => V, y: T | undefined): V | undefined => + y !== undefined ? f(y) : undefined; diff --git a/frontend/src/packages/editor/api/cmsFS/volumes.ts b/frontend/src/packages/editor/api/cmsFS/volumes.ts new file mode 100644 index 00000000..1789d50f --- /dev/null +++ b/frontend/src/packages/editor/api/cmsFS/volumes.ts @@ -0,0 +1,13 @@ +// TODO: remove this and replace with API client once thats complete, see: +// https://github.com/csesoc/website/pull/238 +export const publishDocument = (documentId: string) => { + fetch("/api/filesystem/publish-document", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + DocumentID: `${documentId}`, + }), + }); +} \ No newline at end of file diff --git a/frontend/src/packages/editor/componentFactory.tsx b/frontend/src/packages/editor/componentFactory.tsx new file mode 100644 index 00000000..b06b8fb6 --- /dev/null +++ b/frontend/src/packages/editor/componentFactory.tsx @@ -0,0 +1,71 @@ +import { BaseOperation } from "slate"; +import HeadingBlock from "./components/HeadingBlock"; +import React from "react"; +import { BlockData, UpdateCallback, OpPropagator } from "./types"; +import EditorBlock from "./components/EditorBlock"; +import { OperationManager, slateToCmsOperation } from "./operationManager"; + +// TODO: not now because I want to get this over and done with but the idea of attaching the operation path to the id irks me +// because logically the operation paths aren't actually coupled to the id, it is just a coincidence, ideally the source of the operation path index +// should come from elsewhere + + +type componentFactory = (block: BlockData, blockId: number, isFocused: boolean, onClick: () => void, onUpdate: OpPropagator) => JSX.Element; + +/** + * buildComponentFactory constructs a factory capable of creating CMS components + * @param clickHandler the handler invoked when the element is clicked + * @param updateHandler the handler invoked when teh contents is updated (note: will be deprecated after full transition to OT) + */ +export const buildComponentFactory = (opManager: OperationManager, onClick: (id: number) => void, onUpdate: UpdateCallback) => (block: BlockData, blockId: number, isFocused: boolean) : JSX.Element => { + const constructors: Record = { + "paragraph": buildParagraphBlock, + "heading": buildHeadingBlock + } + + const blockType = block[0].type ?? "unknown"; + const constructor = constructors[blockType]; + if (constructor === undefined) { + throw new Error(`unidentified block type: ${blockType}`) + } + + const updateHandler = buildUpdateHandler(blockId, opManager, onUpdate); + return constructor(block, blockId, isFocused, () => onClick(blockId), updateHandler); +} + +// buildEditorBlock constructs an instance of a normal editor/paragraph block with the specified callback functions +const buildParagraphBlock = (block: BlockData, blockId: number, isFocused: boolean, onClick: () => void, onUpdate: OpPropagator) => + + +// buildHeadingBlock constructs an instance of a heading block with the specified callback functions +const buildHeadingBlock = (block: BlockData, blockId: number, isFocused: boolean, onClick: () => void, onUpdate: OpPropagator) => + + + +// buildUpdateHandler wraps any updates to a component as a nice formatted operation for propagation to the OT server, it then invokes the initial provided handler +// ie the function is just a decorator function :) +const buildUpdateHandler = (blockId: number, opManager: OperationManager, updateCallback: (id: number, update: BlockData) => void) => (id: number, editorContent: BlockData, operations: BaseOperation[]) => { + updateCallback(id, editorContent); + const modifiedOperations = operations.map(operation => { + if (operation.type === "set_selection") { return operation; } + + const modifiedOp = {...operation}; + modifiedOp.path = [blockId].concat([...operation.path]); + + return modifiedOp; + }) + + opManager.pushToServer(slateToCmsOperation(editorContent, modifiedOperations)); +} \ No newline at end of file diff --git a/frontend/src/packages/editor/components/EditorBlock.tsx b/frontend/src/packages/editor/components/EditorBlock.tsx index d6223d93..c296b47d 100644 --- a/frontend/src/packages/editor/components/EditorBlock.tsx +++ b/frontend/src/packages/editor/components/EditorBlock.tsx @@ -9,7 +9,7 @@ import { useSlate, } from "slate-react"; -import { BlockData, UpdateHandler } from "../types"; +import { BlockData, OpPropagator } from "../types"; import EditorBoldButton from "./buttons/EditorBoldButton"; import EditorItalicButton from "./buttons/EditorItalicButton"; import EditorUnderlineButton from "./buttons/EditorUnderlineButton"; @@ -53,7 +53,7 @@ const Text = styled.span<{ const AlignedText = Text.withComponent("div"); interface EditorBlockProps { - update: UpdateHandler; + update: OpPropagator; initialValue: BlockData; id: number; showToolBar: boolean; @@ -110,8 +110,7 @@ const EditorBlock: FC = ({ editor={editor} value={initialValue} onChange={(value) => { - update(id, editor.children); - + update(id, editor.children, editor.operations); dispatch( updateContent({ id: id, @@ -131,7 +130,8 @@ const EditorBlock: FC = ({ )} - + onEditorClick()} diff --git a/frontend/src/packages/editor/components/HeadingBlock.tsx b/frontend/src/packages/editor/components/HeadingBlock.tsx index 2d745fa0..d9cf5ba5 100644 --- a/frontend/src/packages/editor/components/HeadingBlock.tsx +++ b/frontend/src/packages/editor/components/HeadingBlock.tsx @@ -3,7 +3,7 @@ import { createEditor } from "slate"; import React, { FC, useMemo, useCallback } from "react"; import { Slate, Editable, withReact, RenderLeafProps } from "slate-react"; -import { UpdateHandler } from "../types"; +import { BlockData, OpPropagator } from "../types"; import EditorSelectFont from './buttons/EditorSelectFont' import ContentBlock from "../../../cse-ui-kit/contentblock/contentblock-wrapper"; import { handleKey } from "./buttons/buttonHelpers"; @@ -30,9 +30,10 @@ const Text = styled.span<{ `; interface HeadingBlockProps { - update: UpdateHandler; + update: OpPropagator; id: number; showToolBar: boolean; + initialValue: BlockData; onEditorClick: () => void; } @@ -40,6 +41,7 @@ const HeadingBlock: FC = ({ id, update, showToolBar, + initialValue, onEditorClick, }) => { @@ -49,10 +51,8 @@ const HeadingBlock: FC = ({ const renderLeaf: (props: RenderLeafProps) => JSX.Element = useCallback( ({ attributes, children, leaf }) => { return ( - {children} @@ -62,14 +62,12 @@ const HeadingBlock: FC = ({ [] ); - const initialValue = getBlockContent(id); - return ( { - update(id, editor.children) + update(id, editor.children, editor.operations); dispatch(updateContent({ id: id, diff --git a/frontend/src/packages/editor/index.tsx b/frontend/src/packages/editor/index.tsx index 644e771c..36cee200 100644 --- a/frontend/src/packages/editor/index.tsx +++ b/frontend/src/packages/editor/index.tsx @@ -3,20 +3,18 @@ import React, { useState, FC, useRef, useEffect } from "react"; import Client from "./websocketClient"; -import HeadingBlock from "./components/HeadingBlock"; -import EditorBlock from "./components/EditorBlock"; -import { BlockData, UpdateHandler } from "./types"; +import { BlockData, UpdateCallback } from "./types"; import CreateContentBlock from "src/cse-ui-kit/CreateContentBlock_button"; import CreateHeadingBlock from "src/cse-ui-kit/CreateHeadingBlock_button"; import SyncDocument from "src/cse-ui-kit/SyncDocument_button"; import PublishDocument from "src/cse-ui-kit/PublishDocument_button"; import EditorHeader from "src/deprecated/components/Editor/EditorHeader"; -import { addContentBlock } from "./state/actions"; import { useParams } from "react-router-dom"; -import { defaultContent, headingContent } from "./state/helpers"; -// Redux -import { useDispatch } from "react-redux"; +import { buildComponentFactory } from "./componentFactory"; +import { OperationManager } from "./operationManager"; +import { publishDocument } from "./api/cmsFS/volumes"; +import { CMSOperation } from "./api/OTClient/operation"; const Container = styled.div` display: flex; @@ -29,20 +27,22 @@ const InsertContentWrapper = styled.div` `; const EditorPage: FC = () => { - const dispatch = useDispatch(); const { id } = useParams(); const wsClient = useRef(null); + const opManager = useRef(new OperationManager()); const [blocks, setBlocks] = useState([]); const [focusedId, setFocusedId] = useState(0); - const updateValues: UpdateHandler = (idx, updatedBlock) => { - if (JSON.stringify(blocks[idx]) === JSON.stringify(updateValues)) return; - setBlocks((prev) => - prev.map((block, i) => (i === idx ? updatedBlock : block)) - ); + const updateValues: UpdateCallback = (idx, updatedBlock) => { + const requiresUpdate = JSON.stringify(blocks[idx]) !== JSON.stringify(updateValues); + if (!requiresUpdate) return; + + setBlocks((prev) => prev.map((block, i) => (i === idx ? updatedBlock : block))); }; + const createBlock = buildComponentFactory(opManager.current ?? new OperationManager(), setFocusedId, updateValues); + useEffect(() => { function cleanup() { wsClient.current?.close(); @@ -50,14 +50,10 @@ const EditorPage: FC = () => { wsClient.current = new Client( id as string, - (data) => { - console.log(id, JSON.stringify(data)); - setBlocks(data as BlockData[]); - }, - (reason) => { - console.log(reason); - } + (data) => { setBlocks(data as BlockData[]); }, + (reason) => { console.log(`Server connection terminated, reason: ${reason}`); } ); + window.addEventListener("beforeunload", cleanup); return () => { console.log("Editor component destroyed"); @@ -66,92 +62,49 @@ const EditorPage: FC = () => { }; }, []); + // TODO: remove me once OT integration is complete + const syncDocument = () => { + if (wsClient.current?.socket.readyState === WebSocket.OPEN) { + console.log(JSON.stringify(blocks)); + wsClient.current?.pushDocumentData(JSON.stringify(blocks)); + } + } + + // buildClickHandler builds handlers for events where new blocks are created and propagates them to the OT manager + const buildButtonClickHandler = (type: "heading" | "paragraph") => () => { + const newElement = { type: type, children: [{ text: "" }] }; + + // push and update this creation operation to the operation manager + setBlocks((prev) => [...prev, [newElement]]); + setFocusedId(blocks.length); + opManager.current?.pushToServer(newCreationOperation(newElement, blocks.length)); + } + return (
- {blocks.map((block, idx) => { - console.log(block[0].type); - return block[0].type === "paragraph" ? ( - setFocusedId(idx)} - /> - ) : ( - setFocusedId(idx)} - /> - ); - })} - + {blocks.map((block, idx) => createBlock(block, idx, focusedId === idx))} - { - setBlocks((prev) => [ - ...prev, - [{ type: "heading", children: [{ text: "" }] }], - ]); - - // create the initial state of the content block to Redux - dispatch( - addContentBlock({ - id: blocks.length, - data: headingContent, - }) - ); - setFocusedId(blocks.length); - }} - /> - { - setBlocks((prev) => [ - ...prev, - [{ type: "paragraph", children: [{ text: "" }] }], - ]); - - // create the initial state of the content block to Redux - dispatch( - addContentBlock({ - id: blocks.length, - data: defaultContent, - }) - ); - setFocusedId(blocks.length); - }} - /> - { - if (wsClient.current?.socket.readyState === WebSocket.OPEN) { - console.log(JSON.stringify(blocks)); - wsClient.current?.pushDocumentData(JSON.stringify(blocks)); - } - }} - /> - { - fetch("/api/filesystem/publish-document", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - DocumentID: `${id}`, - }), - }); - }} - /> + + + syncDocument()} /> + publishDocument(id ?? "")} />
); }; -export default EditorPage; +// constructs a new creation operation in response to the insertion of a new paragraph/heading +const newCreationOperation = (newValue: any, index: number): CMSOperation => ({ + Path: [index], + OperationType: "insert", + IsNoOp: false, + Operation: { + "$type": "objectOperation", + objectOperation: { newValue }, + } +}); + +export default EditorPage; \ No newline at end of file diff --git a/frontend/src/packages/editor/operationManager.tsx b/frontend/src/packages/editor/operationManager.tsx new file mode 100644 index 00000000..afeb950d --- /dev/null +++ b/frontend/src/packages/editor/operationManager.tsx @@ -0,0 +1,33 @@ +// operationManager is a centralized location for dealing with captured operations from anywhere within the editor +// these operations are shoved along and propagated to the server :) + +import { BaseOperation } from "slate"; +import { CMSOperation } from "./api/OTClient/operation"; +import { BlockData } from "./types"; + +export class OperationManager { + public pushToServer = (operation: CMSOperation) => { + // todo: reformat the operation to follow the structure of a CMS operation + // drawing upon the editor content as a relative data source + + // todo: remove console.logs after completion + console.log("operation: ", operation); + } +} + +export const slateToCmsOperation = (editorContent: BlockData, operation: BaseOperation[]) : CMSOperation => { + // TODO: remove console.logs after full completion :D + // console.log("content: ", editorContent); + // console.log("operation: ", operation); + + // TODO: implement me :D + return { + Path: [1, 2, 3], + OperationType: "insert", + IsNoOp: false, + Operation: { + $type: "noop", + noop: {} + } + } +} \ No newline at end of file diff --git a/frontend/src/packages/editor/types.ts b/frontend/src/packages/editor/types.ts index add1cffb..e09c8479 100644 --- a/frontend/src/packages/editor/types.ts +++ b/frontend/src/packages/editor/types.ts @@ -1,11 +1,13 @@ import { ReactEditor } from "slate-react"; -import { BaseEditor, Descendant } from "slate"; +import { BaseEditor, BaseOperation, Descendant } from "slate"; export type BlockData = Descendant[]; -export type UpdateHandler = (idx: number, updatedBlock: BlockData) => void; + +export type OpPropagator = (id: number, update: BlockData, operation: BaseOperation[]) => void; +export type UpdateCallback = (id: number, update: BlockData) => void; type CustomElement = { type: "paragraph" | "heading"; children: CustomText[] }; -type CustomText = { +export type CustomText = { textSize?: number; text: string; bold?: boolean; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 0ffdeca3..5d8fa965 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "ES2015", "lib": [ "dom", "dom.iterable", From 42dea62bcb6b658c380598b7025387a91ebcfbac Mon Sep 17 00:00:00 2001 From: Varun Sethu Date: Tue, 1 Nov 2022 17:33:42 +1100 Subject: [PATCH 2/4] code cleanup + refactor --- .../src/packages/editor/componentFactory.tsx | 50 +++++------- .../editor/components/EditorBlock.tsx | 76 +++++-------------- .../editor/components/HeadingBlock.tsx | 27 +------ frontend/src/packages/editor/types.ts | 9 +++ 4 files changed, 49 insertions(+), 113 deletions(-) diff --git a/frontend/src/packages/editor/componentFactory.tsx b/frontend/src/packages/editor/componentFactory.tsx index b06b8fb6..df818011 100644 --- a/frontend/src/packages/editor/componentFactory.tsx +++ b/frontend/src/packages/editor/componentFactory.tsx @@ -1,7 +1,7 @@ import { BaseOperation } from "slate"; import HeadingBlock from "./components/HeadingBlock"; import React from "react"; -import { BlockData, UpdateCallback, OpPropagator } from "./types"; +import { BlockData, UpdateCallback, CMSBlockProps } from "./types"; import EditorBlock from "./components/EditorBlock"; import { OperationManager, slateToCmsOperation } from "./operationManager"; @@ -10,53 +10,41 @@ import { OperationManager, slateToCmsOperation } from "./operationManager"; // should come from elsewhere -type componentFactory = (block: BlockData, blockId: number, isFocused: boolean, onClick: () => void, onUpdate: OpPropagator) => JSX.Element; +type callbackHandler = (id: number, update: BlockData) => void; + +// registration of all block constructors +const constructors: Record JSX.Element> = { + "paragraph": (props) => , + "heading": (props) => +} /** * buildComponentFactory constructs a factory capable of creating CMS components + * @param opManager the global operation manager * @param clickHandler the handler invoked when the element is clicked * @param updateHandler the handler invoked when teh contents is updated (note: will be deprecated after full transition to OT) */ export const buildComponentFactory = (opManager: OperationManager, onClick: (id: number) => void, onUpdate: UpdateCallback) => (block: BlockData, blockId: number, isFocused: boolean) : JSX.Element => { - const constructors: Record = { - "paragraph": buildParagraphBlock, - "heading": buildHeadingBlock - } - + const componentProps = { + id: blockId, + showToolBar: isFocused, + initialValue: block, + update: buildUpdateHandler(blockId, opManager, onUpdate), + onEditorClick: () => onClick(blockId), + }; + const blockType = block[0].type ?? "unknown"; const constructor = constructors[blockType]; if (constructor === undefined) { throw new Error(`unidentified block type: ${blockType}`) } - const updateHandler = buildUpdateHandler(blockId, opManager, onUpdate); - return constructor(block, blockId, isFocused, () => onClick(blockId), updateHandler); + return constructor(componentProps); } -// buildEditorBlock constructs an instance of a normal editor/paragraph block with the specified callback functions -const buildParagraphBlock = (block: BlockData, blockId: number, isFocused: boolean, onClick: () => void, onUpdate: OpPropagator) => - - -// buildHeadingBlock constructs an instance of a heading block with the specified callback functions -const buildHeadingBlock = (block: BlockData, blockId: number, isFocused: boolean, onClick: () => void, onUpdate: OpPropagator) => - - - // buildUpdateHandler wraps any updates to a component as a nice formatted operation for propagation to the OT server, it then invokes the initial provided handler // ie the function is just a decorator function :) -const buildUpdateHandler = (blockId: number, opManager: OperationManager, updateCallback: (id: number, update: BlockData) => void) => (id: number, editorContent: BlockData, operations: BaseOperation[]) => { +const buildUpdateHandler = (blockId: number, opManager: OperationManager, updateCallback: callbackHandler) => (id: number, editorContent: BlockData, operations: BaseOperation[]) => { updateCallback(id, editorContent); const modifiedOperations = operations.map(operation => { if (operation.type === "set_selection") { return operation; } diff --git a/frontend/src/packages/editor/components/EditorBlock.tsx b/frontend/src/packages/editor/components/EditorBlock.tsx index c296b47d..9a54d351 100644 --- a/frontend/src/packages/editor/components/EditorBlock.tsx +++ b/frontend/src/packages/editor/components/EditorBlock.tsx @@ -1,15 +1,14 @@ import styled from "styled-components"; -import { createEditor, Descendant } from "slate"; +import { createEditor } from "slate"; import React, { FC, useMemo, useCallback } from "react"; import { Slate, Editable, withReact, RenderLeafProps, - useSlate, } from "slate-react"; -import { BlockData, OpPropagator } from "../types"; +import { CMSBlockProps } from "../types"; import EditorBoldButton from "./buttons/EditorBoldButton"; import EditorItalicButton from "./buttons/EditorItalicButton"; import EditorUnderlineButton from "./buttons/EditorUnderlineButton"; @@ -19,12 +18,7 @@ import EditorLeftAlignButton from "./buttons/EditorLeftAlignButton"; import EditorRightAlignButton from "./buttons/EditorRightAlignButton"; import ContentBlock from "../../../cse-ui-kit/contentblock/contentblock-wrapper"; -import { toggleMark, handleKey } from "./buttons/buttonHelpers"; -import { getBlockContent } from "../state/helpers"; - -// Redux -import { useDispatch } from "react-redux"; -import { updateContent } from "../state/actions"; +import { handleKey } from "./buttons/buttonHelpers"; const defaultTextSize = 16; @@ -52,72 +46,38 @@ const Text = styled.span<{ const AlignedText = Text.withComponent("div"); -interface EditorBlockProps { - update: OpPropagator; - initialValue: BlockData; - id: number; - showToolBar: boolean; - onEditorClick: () => void; -} - -const EditorBlock: FC = ({ +const EditorBlock: FC = ({ id, update, initialValue, showToolBar, onEditorClick, }) => { - const dispatch = useDispatch(); const editor = useMemo(() => withReact(createEditor()), []); const renderLeaf: (props: RenderLeafProps) => JSX.Element = useCallback( ({ attributes, children, leaf }) => { - return leaf.align == null ? ( - - {children} - - ) : ( - - {children} - - ); - }, + const props = { + bold: leaf.bold ?? false, + italic: leaf.italic ?? false, + underline: leaf.underline ?? false, + align: leaf.align ?? "left", + textSize: leaf.textSize ?? defaultTextSize, + ...attributes + } + + return leaf.align == null + ? {children} + : {children}; + }, [] ); - // const initialValue = getBlockContent(id); - return ( { - update(id, editor.children, editor.operations); - dispatch( - updateContent({ - id: id, - data: value, - }) - ); - }} + onChange={(value) => update(id, editor.children, editor.operations)} > {showToolBar && ( diff --git a/frontend/src/packages/editor/components/HeadingBlock.tsx b/frontend/src/packages/editor/components/HeadingBlock.tsx index d9cf5ba5..52801b3f 100644 --- a/frontend/src/packages/editor/components/HeadingBlock.tsx +++ b/frontend/src/packages/editor/components/HeadingBlock.tsx @@ -3,15 +3,10 @@ import { createEditor } from "slate"; import React, { FC, useMemo, useCallback } from "react"; import { Slate, Editable, withReact, RenderLeafProps } from "slate-react"; -import { BlockData, OpPropagator } from "../types"; +import { CMSBlockProps } from "../types"; import EditorSelectFont from './buttons/EditorSelectFont' import ContentBlock from "../../../cse-ui-kit/contentblock/contentblock-wrapper"; import { handleKey } from "./buttons/buttonHelpers"; -import { getBlockContent } from "../state/helpers"; - -// Redux -import { useDispatch } from "react-redux"; -import {updateContent} from "../state/actions"; const defaultTextSize = 24; @@ -29,23 +24,14 @@ const Text = styled.span<{ font-size: ${(props) => (props.textSize)}px; `; -interface HeadingBlockProps { - update: OpPropagator; - id: number; - showToolBar: boolean; - initialValue: BlockData; - onEditorClick: () => void; -} -const HeadingBlock: FC = ({ +const HeadingBlock: FC = ({ id, update, showToolBar, initialValue, onEditorClick, }) => { - - const dispatch = useDispatch(); const editor = useMemo(() => withReact(createEditor()), []); const renderLeaf: (props: RenderLeafProps) => JSX.Element = useCallback( @@ -66,14 +52,7 @@ const HeadingBlock: FC = ({ { - update(id, editor.children, editor.operations); - - dispatch(updateContent({ - id: id, - data: value, - })) - }} + onChange={(value) => { update(id, editor.children, editor.operations); }} > {showToolBar && ( diff --git a/frontend/src/packages/editor/types.ts b/frontend/src/packages/editor/types.ts index e09c8479..36fbcf25 100644 --- a/frontend/src/packages/editor/types.ts +++ b/frontend/src/packages/editor/types.ts @@ -17,6 +17,15 @@ export type CustomText = { align?: string; }; +export interface CMSBlockProps { + update: OpPropagator; + initialValue: BlockData; + id: number; + showToolBar: boolean; + onEditorClick: () => void; +} + + declare module "slate" { interface CustomTypes { Editor: BaseEditor & ReactEditor; From 88a69cc035ab3a8ef1834efeef7c02ab73226ed7 Mon Sep 17 00:00:00 2001 From: Varun Sethu Date: Tue, 1 Nov 2022 17:41:08 +1100 Subject: [PATCH 3/4] trying to resolve broken pipeline --- frontend/src/packages/editor/componentFactory.tsx | 1 + frontend/src/packages/editor/operationManager.tsx | 6 +++--- postgres/Dockerfile | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/packages/editor/componentFactory.tsx b/frontend/src/packages/editor/componentFactory.tsx index df818011..81e658ab 100644 --- a/frontend/src/packages/editor/componentFactory.tsx +++ b/frontend/src/packages/editor/componentFactory.tsx @@ -27,6 +27,7 @@ const constructors: Record JSX.Element> = { export const buildComponentFactory = (opManager: OperationManager, onClick: (id: number) => void, onUpdate: UpdateCallback) => (block: BlockData, blockId: number, isFocused: boolean) : JSX.Element => { const componentProps = { id: blockId, + key: blockId, showToolBar: isFocused, initialValue: block, update: buildUpdateHandler(blockId, opManager, onUpdate), diff --git a/frontend/src/packages/editor/operationManager.tsx b/frontend/src/packages/editor/operationManager.tsx index afeb950d..be8c8aff 100644 --- a/frontend/src/packages/editor/operationManager.tsx +++ b/frontend/src/packages/editor/operationManager.tsx @@ -11,14 +11,14 @@ export class OperationManager { // drawing upon the editor content as a relative data source // todo: remove console.logs after completion - console.log("operation: ", operation); + // console.log("operation: ", operation); } } export const slateToCmsOperation = (editorContent: BlockData, operation: BaseOperation[]) : CMSOperation => { // TODO: remove console.logs after full completion :D - // console.log("content: ", editorContent); - // console.log("operation: ", operation); + console.log("content: ", editorContent); + console.log("operation: ", operation); // TODO: implement me :D return { diff --git a/postgres/Dockerfile b/postgres/Dockerfile index b432f298..a30086d1 100644 --- a/postgres/Dockerfile +++ b/postgres/Dockerfile @@ -1,6 +1,7 @@ -FROM python:slim +FROM python:3.6-alpine COPY requirements.txt . +RUN apk update && apk add postgresql-dev gcc python3-dev musl-dev RUN pip install -r requirements.txt COPY . . From 5e9dcfc856c271427c1916e02ea10985d2f23265 Mon Sep 17 00:00:00 2001 From: Varun Sethu Date: Wed, 2 Nov 2022 20:27:18 +1100 Subject: [PATCH 4/4] review changes --- postgres/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/postgres/Dockerfile b/postgres/Dockerfile index a30086d1..a7324a6b 100644 --- a/postgres/Dockerfile +++ b/postgres/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.6-alpine +FROM python:alpine COPY requirements.txt . RUN apk update && apk add postgresql-dev gcc python3-dev musl-dev