From 6425a6a0e0360174a29d6cb18015848eed9081fc Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Sun, 31 May 2026 23:18:39 -0400 Subject: [PATCH 01/57] chore(deps): install slate and rehype-raw --- apps/frontend/package-lock.json | 326 ++++++++++++++++++++++++++++++-- apps/frontend/package.json | 4 + 2 files changed, 314 insertions(+), 16 deletions(-) diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index 7e84778ac..c6d5eb39c 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -40,7 +40,11 @@ "react-transition-group": "^4.4.5", "recharts": "^2.12.0", "redux": "^4.2.1", + "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", + "slate": "^0.124.1", + "slate-history": "^0.113.1", + "slate-react": "^0.124.2", "uuid": "^11.1.0", "vite": "^6.3.5", "vite-plugin-svgr": "^4.3.0", @@ -4014,6 +4018,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@juggle/resize-observer": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", + "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", + "license": "Apache-2.0" + }, "node_modules/@material-ui/core": { "version": "4.12.4", "resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.12.4.tgz", @@ -9985,6 +9995,12 @@ "node": ">= 10" } }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "license": "MIT" @@ -11376,6 +11392,19 @@ "node": ">=8" } }, + "node_modules/direction": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz", + "integrity": "sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==", + "license": "MIT", + "bin": { + "direction": "cli.js" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/doctrine": { "version": "3.0.0", "dev": true, @@ -11445,11 +11474,14 @@ } }, "node_modules/dompurify": { - "version": "2.5.8", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.8.tgz", - "integrity": "sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==", + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz", + "integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==", "license": "(MPL-2.0 OR Apache-2.0)", - "optional": true + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } }, "node_modules/domutils": { "version": "3.2.2", @@ -13588,6 +13620,74 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5/node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.0.tgz", @@ -13614,6 +13714,35 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5/node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/hast-util-whitespace": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", @@ -13626,6 +13755,33 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript/node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "license": "BSD-3-Clause", @@ -13679,6 +13835,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/html2canvas": { "version": "1.4.1", "license": "MIT", @@ -13701,16 +13867,6 @@ "jspdf": "^3.0.0" } }, - "node_modules/html2pdf.js/node_modules/dompurify": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.5.tgz", - "integrity": "sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optional": true, - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/html2pdf.js/node_modules/jspdf": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.4.tgz", @@ -14180,6 +14336,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-hotkey": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz", + "integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==", + "license": "MIT" + }, "node_modules/is-in-browser": { "version": "1.1.3", "license": "MIT" @@ -14231,6 +14393,15 @@ "node": ">=8" } }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "dev": true, @@ -14695,6 +14866,13 @@ "html2canvas": "^1.0.0-rc.5" } }, + "node_modules/jspdf/node_modules/dompurify": { + "version": "2.5.9", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.9.tgz", + "integrity": "sha512-i6mvVmWN4xo9LrhCOZrDgSs9noW6nOahbrmzjRbPF36YPyj5Ue5lgok0MHDWkG7xzpWFO2OYttXdzM7rJxHvNA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true + }, "node_modules/jss": { "version": "10.10.0", "license": "MIT", @@ -16689,7 +16867,6 @@ "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, "license": "MIT", "dependencies": { "entities": "^6.0.0" @@ -16702,7 +16879,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -17579,6 +17755,21 @@ } } }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -18032,6 +18223,15 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, "node_modules/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", @@ -18240,6 +18440,76 @@ "node": ">=8" } }, + "node_modules/slate": { + "version": "0.124.1", + "resolved": "https://registry.npmjs.org/slate/-/slate-0.124.1.tgz", + "integrity": "sha512-ii7DwezgvbLAyKtHBIunjTR1kzbNfYLCUKLMzJELlbTZkvHzX4DzN7HKIwcakf6dPxO6AoeT/P7kHOcyTym/hA==", + "license": "MIT" + }, + "node_modules/slate-dom": { + "version": "0.124.1", + "resolved": "https://registry.npmjs.org/slate-dom/-/slate-dom-0.124.1.tgz", + "integrity": "sha512-D3yVibjLZM4Oj4MmXxOEXbjrlf4wJez3OvGABBNYrAP7gXb0d96tKNtWZ0hGm/5y84idw/LHjZ7W1uTYqFR9rQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@juggle/resize-observer": "^3.4.0", + "direction": "^1.0.4", + "is-hotkey": "^0.2.0", + "is-plain-object": "^5.0.0", + "lodash": "^4.17.21", + "scroll-into-view-if-needed": "^3.1.0", + "tiny-invariant": "1.3.1" + }, + "peerDependencies": { + "slate": ">=0.121.0" + } + }, + "node_modules/slate-dom/node_modules/tiny-invariant": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", + "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==", + "license": "MIT", + "peer": true + }, + "node_modules/slate-history": { + "version": "0.113.1", + "resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.113.1.tgz", + "integrity": "sha512-J9NSJ+UG2GxoW0lw5mloaKcN0JI0x2IA5M5FxyGiInpn+QEutxT1WK7S/JneZCMFJBoHs1uu7S7e6pxQjubHmQ==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^5.0.0" + }, + "peerDependencies": { + "slate": ">=0.65.3" + } + }, + "node_modules/slate-react": { + "version": "0.124.2", + "resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.124.2.tgz", + "integrity": "sha512-2B6ZZX5qKYeISNaQ9cDuvvp0tWydnHUPuGxyVJZfjD+W00X34dGaAF/5ZyVMZt/O//sf0Glbgd7+NBHRWjiA7Q==", + "license": "MIT", + "dependencies": { + "@juggle/resize-observer": "^3.4.0", + "direction": "^1.0.4", + "is-hotkey": "^0.2.0", + "lodash": "^4.17.21", + "scroll-into-view-if-needed": "^3.1.0", + "tiny-invariant": "1.3.1" + }, + "peerDependencies": { + "react": ">=18.2.0", + "react-dom": ">=18.2.0", + "slate": ">=0.121.0", + "slate-dom": ">=0.119.1" + } + }, + "node_modules/slate-react/node_modules/tiny-invariant": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", + "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==", + "license": "MIT" + }, "node_modules/snake-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", @@ -19819,6 +20089,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vfile-message": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", @@ -20542,6 +20826,16 @@ "node": ">=10.13.0" } }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/web-vitals": { "version": "2.1.4", "license": "Apache-2.0" diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 772cee9f0..32db0074e 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -53,7 +53,11 @@ "react-transition-group": "^4.4.5", "recharts": "^2.12.0", "redux": "^4.2.1", + "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", + "slate": "^0.124.1", + "slate-history": "^0.113.1", + "slate-react": "^0.124.2", "uuid": "^11.1.0", "vite": "^6.3.5", "vite-plugin-svgr": "^4.3.0", From 13392f927c6a31bd6db2cb35daa9f9447d96b37a Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Sun, 31 May 2026 23:24:59 -0400 Subject: [PATCH 02/57] add custom editor types and utilities for list formatting --- .../src/components/RichTextEditor/types.ts | 51 +++++++++++ .../RichTextEditor/utils/editorGuards.ts | 17 ++++ .../RichTextEditor/utils/editorTransforms.ts | 88 +++++++++++++++++++ .../RichTextEditor/utils/elementFactory.ts | 21 +++++ 4 files changed, 177 insertions(+) create mode 100644 apps/frontend/src/components/RichTextEditor/types.ts create mode 100644 apps/frontend/src/components/RichTextEditor/utils/editorGuards.ts create mode 100644 apps/frontend/src/components/RichTextEditor/utils/editorTransforms.ts create mode 100644 apps/frontend/src/components/RichTextEditor/utils/elementFactory.ts diff --git a/apps/frontend/src/components/RichTextEditor/types.ts b/apps/frontend/src/components/RichTextEditor/types.ts new file mode 100644 index 000000000..fee666cde --- /dev/null +++ b/apps/frontend/src/components/RichTextEditor/types.ts @@ -0,0 +1,51 @@ +import type { BaseEditor } from "slate"; +import type { HistoryEditor } from "slate-history"; +import type { ReactEditor } from "slate-react"; + +export type MarkFormat = "bold" | "italic" | "underline"; + +export type TextMarks = Partial>; + +export type ListFormat = "bulleted-list" | "numbered-list"; + +export type BlockFormat = "paragraph" | ListFormat | "list-item"; + +export type FormattedText = { + text: string; +} & TextMarks; + +export type CustomText = FormattedText; + +export type ParagraphElement = { + type: "paragraph"; + children: CustomText[]; +}; + +export type ListItemElement = { + type: "list-item"; + children: CustomText[]; +}; + +export type BulletedListElement = { + type: "bulleted-list"; + children: ListItemElement[]; +}; + +export type NumberedListElement = { + type: "numbered-list"; + children: ListItemElement[]; +}; + +export type ListElement = BulletedListElement | NumberedListElement; + +export type CustomElement = ParagraphElement | ListElement | ListItemElement; + +export type CustomEditor = BaseEditor & ReactEditor & HistoryEditor; + +declare module "slate" { + interface CustomTypes { + Editor: CustomEditor; + Element: CustomElement; + Text: CustomText; + } +} diff --git a/apps/frontend/src/components/RichTextEditor/utils/editorGuards.ts b/apps/frontend/src/components/RichTextEditor/utils/editorGuards.ts new file mode 100644 index 000000000..05f904e43 --- /dev/null +++ b/apps/frontend/src/components/RichTextEditor/utils/editorGuards.ts @@ -0,0 +1,17 @@ +import { Element, Node } from "slate"; + +import type { BlockFormat, ListElement, ListFormat } from "../types"; + +const LIST_FORMATS: readonly ListFormat[] = ["bulleted-list", "numbered-list"]; + +/** + * Narrows a block format to a list format. + */ +export const isListFormat = (format: BlockFormat): format is ListFormat => + LIST_FORMATS.includes(format as ListFormat); + +/** + * Narrows a Slate node to one of the supported list elements. + */ +export const isListElement = (node: Node): node is ListElement => + Element.isElement(node) && isListFormat(node.type as BlockFormat); diff --git a/apps/frontend/src/components/RichTextEditor/utils/editorTransforms.ts b/apps/frontend/src/components/RichTextEditor/utils/editorTransforms.ts new file mode 100644 index 000000000..8e4a91214 --- /dev/null +++ b/apps/frontend/src/components/RichTextEditor/utils/editorTransforms.ts @@ -0,0 +1,88 @@ +import { Editor, Element, Transforms } from "slate"; +import { withHistory } from "slate-history"; +import { withReact } from "slate-react"; + +import type { BlockFormat, CustomEditor, CustomElement, ListFormat, MarkFormat } from "../types"; + +import { isListElement, isListFormat } from "./editorGuards"; +import { createListElement } from "./elementFactory"; + +/** + * Adds React and history behavior to a base Slate editor. + */ +export const withCustomEditor = (editor: Editor): CustomEditor => + withHistory(withReact(editor)) as CustomEditor; + +/** + * Returns true when the current selection has the requested text mark. + */ +export const isMarkActive = (editor: CustomEditor, format: MarkFormat): boolean => { + const marks = Editor.marks(editor); + + if (!marks) { + return false; + } + + return marks[format] === true; +}; + +/** + * Toggles a text mark for the current selection. + */ +export const toggleMark = (editor: CustomEditor, format: MarkFormat): void => { + if (isMarkActive(editor, format)) { + Editor.removeMark(editor, format); + return; + } + + Editor.addMark(editor, format, true); +}; + +/** + * Returns true when the current selection is inside the requested block format. + */ +export const isBlockActive = (editor: CustomEditor, format: BlockFormat): boolean => { + const [match] = Array.from( + Editor.nodes(editor, { + match: (node) => !Editor.isEditor(node) && Element.isElement(node) && node.type === format, + }) + ); + + return Boolean(match); +}; + +const getNextBlockType = (format: BlockFormat, isActive: boolean): BlockFormat => { + if (isActive) { + return "paragraph"; + } + + if (isListFormat(format)) { + return "list-item"; + } + + return format; +}; + +const unwrapExistingLists = (editor: CustomEditor): void => { + Transforms.unwrapNodes(editor, { + match: (node) => !Editor.isEditor(node) && isListElement(node), + split: true, + }); +}; + +/** + * Toggles the current block between paragraph, list item, and list container states. + */ +export const toggleBlock = (editor: CustomEditor, format: BlockFormat): void => { + const blockIsActive = isBlockActive(editor, format); + const nextBlockType = getNextBlockType(format, blockIsActive); + + unwrapExistingLists(editor); + Transforms.setNodes(editor, { type: nextBlockType }); + + if (blockIsActive || !isListFormat(format)) { + return; + } + + Transforms.wrapNodes(editor, createListElement(format as ListFormat)); +}; diff --git a/apps/frontend/src/components/RichTextEditor/utils/elementFactory.ts b/apps/frontend/src/components/RichTextEditor/utils/elementFactory.ts new file mode 100644 index 000000000..0b0027a4b --- /dev/null +++ b/apps/frontend/src/components/RichTextEditor/utils/elementFactory.ts @@ -0,0 +1,21 @@ +import type { FormattedText, ListElement, ListFormat, ListItemElement } from "../types"; + +const EMPTY_TEXT_NODE: FormattedText = { text: "" }; + +/** + * Creates a Slate list container for the requested list format. + */ +export const createListElement = (format: ListFormat): ListElement => ({ + type: format, + children: [], +}); + +/** + * Creates a Slate list item with an empty text node by default. + */ +export const createListItem = ( + children: FormattedText[] = [{ ...EMPTY_TEXT_NODE }] +): ListItemElement => ({ + type: "list-item", + children, +}); From 86b5c5697baed16e6e096da4cdc16f10633c8c74 Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Sun, 31 May 2026 23:27:35 -0400 Subject: [PATCH 03/57] feat: create editor toolbar --- .../src/components/RichTextEditor/Toolbar.tsx | 39 +++++ .../RichTextEditor/ToolbarButton.tsx | 165 ++++++++++++++++++ apps/frontend/src/config/toolbarConfig.ts | 27 +++ 3 files changed, 231 insertions(+) create mode 100644 apps/frontend/src/components/RichTextEditor/Toolbar.tsx create mode 100644 apps/frontend/src/components/RichTextEditor/ToolbarButton.tsx create mode 100644 apps/frontend/src/config/toolbarConfig.ts diff --git a/apps/frontend/src/components/RichTextEditor/Toolbar.tsx b/apps/frontend/src/components/RichTextEditor/Toolbar.tsx new file mode 100644 index 000000000..51b50f9af --- /dev/null +++ b/apps/frontend/src/components/RichTextEditor/Toolbar.tsx @@ -0,0 +1,39 @@ +import { Box, styled } from "@mui/material"; +import type { ReactElement } from "react"; + +import { BLOCK_BUTTONS, MARK_BUTTONS } from "../../config/toolbarConfig"; + +import { BlockButton, MarkButton, RedoButton, UndoButton } from "./ToolbarButton"; + +const StyledToolbar = styled(Box)({ + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: "2px", + padding: "6px 8px", + borderBottom: "1px solid #E0E0E0", + flexWrap: "wrap", +}); + +const ToolbarDivider = styled(Box)({ + width: "1px", + height: "20px", + backgroundColor: "#E0E0E0", + margin: "0 4px", +}); + +const Toolbar = (): ReactElement => ( + + {MARK_BUTTONS.map((button) => ( + + ))} + {BLOCK_BUTTONS.map((button) => ( + + ))} + + + + +); + +export default Toolbar; diff --git a/apps/frontend/src/components/RichTextEditor/ToolbarButton.tsx b/apps/frontend/src/components/RichTextEditor/ToolbarButton.tsx new file mode 100644 index 000000000..62263f130 --- /dev/null +++ b/apps/frontend/src/components/RichTextEditor/ToolbarButton.tsx @@ -0,0 +1,165 @@ +import RedoIcon from "@mui/icons-material/Redo"; +import UndoIcon from "@mui/icons-material/Undo"; +import { IconButton, Tooltip, styled } from "@mui/material"; +import type { SvgIconProps } from "@mui/material"; +import type { ElementType, MouseEvent, ReactElement } from "react"; +import { HistoryEditor } from "slate-history"; +import { useSlate } from "slate-react"; + +import type { BlockFormat, MarkFormat } from "./types"; +import { isBlockActive, isMarkActive, toggleBlock, toggleMark } from "./utils/editorTransforms"; + +const StyledIconButton = styled(IconButton, { + shouldForwardProp: (prop) => prop !== "active", +})<{ active?: boolean }>(({ active }) => ({ + borderRadius: "4px", + padding: "4px", + color: active ? "#1976d2" : "#616161", + backgroundColor: active ? "rgba(25, 118, 210, 0.08)" : "transparent", + "&:hover": { + backgroundColor: active ? "rgba(25, 118, 210, 0.12)" : "rgba(0, 0, 0, 0.04)", + }, +})); + +export type ToolbarIcon = ElementType; + +export type MarkButtonConfig = { + format: MarkFormat; + tooltip: string; + icon: ToolbarIcon; +}; + +export type BlockButtonConfig = { + format: BlockFormat; + tooltip: string; + icon: ToolbarIcon; +}; + +type Props = { + label: string; + tooltip: string; + icon: ToolbarIcon; + active?: boolean; + pressed?: boolean; + disabled?: boolean; + onMouseDown: (event: MouseEvent) => void; +}; + +const ToolbarButton = ({ + label, + tooltip, + icon: Icon, + active = false, + pressed, + disabled = false, + onMouseDown, +}: Props): ReactElement => ( + + + + + + + +); + +/** + * Toolbar button that toggles an inline mark for the active Slate selection. + */ +export const MarkButton = ({ format, tooltip, icon }: MarkButtonConfig): ReactElement => { + const editor = useSlate(); + const active = isMarkActive(editor, format); + + const handleMouseDown = (event: MouseEvent): void => { + event.preventDefault(); + toggleMark(editor, format); + }; + + return ( + + ); +}; + +/** + * Toolbar button that toggles a block format for the active Slate selection. + */ +export const BlockButton = ({ format, tooltip, icon }: BlockButtonConfig): ReactElement => { + const editor = useSlate(); + const active = isBlockActive(editor, format); + + const handleMouseDown = (event: MouseEvent): void => { + event.preventDefault(); + toggleBlock(editor, format); + }; + + return ( + + ); +}; + +/** + * Toolbar button that performs Slate history undo. + */ +export const UndoButton = (): ReactElement => { + const editor = useSlate(); + const canUndo = editor.history.undos.length > 0; + + const handleMouseDown = (event: MouseEvent): void => { + event.preventDefault(); + HistoryEditor.undo(editor); + }; + + return ( + + ); +}; + +/** + * Toolbar button that performs Slate history redo. + */ +export const RedoButton = (): ReactElement => { + const editor = useSlate(); + const canRedo = editor.history.redos.length > 0; + + const handleMouseDown = (event: MouseEvent): void => { + event.preventDefault(); + HistoryEditor.redo(editor); + }; + + return ( + + ); +}; diff --git a/apps/frontend/src/config/toolbarConfig.ts b/apps/frontend/src/config/toolbarConfig.ts new file mode 100644 index 000000000..100ba9291 --- /dev/null +++ b/apps/frontend/src/config/toolbarConfig.ts @@ -0,0 +1,27 @@ +import FormatBoldIcon from "@mui/icons-material/FormatBold"; +import FormatItalicIcon from "@mui/icons-material/FormatItalic"; +import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted"; +import FormatListNumberedIcon from "@mui/icons-material/FormatListNumbered"; +import FormatUnderlinedIcon from "@mui/icons-material/FormatUnderlined"; + +import type { + BlockButtonConfig, + MarkButtonConfig, +} from "../components/RichTextEditor/ToolbarButton"; + +/** + * Inline mark buttons rendered in the editor toolbar. + */ +export const MARK_BUTTONS: MarkButtonConfig[] = [ + { format: "bold", tooltip: "Bold (Ctrl+B)", icon: FormatBoldIcon }, + { format: "italic", tooltip: "Italic (Ctrl+I)", icon: FormatItalicIcon }, + { format: "underline", tooltip: "Underline (Ctrl+U)", icon: FormatUnderlinedIcon }, +]; + +/** + * Block-format buttons rendered in the editor toolbar. + */ +export const BLOCK_BUTTONS: BlockButtonConfig[] = [ + { format: "bulleted-list", tooltip: "Bullet List", icon: FormatListBulletedIcon }, + { format: "numbered-list", tooltip: "Numbered List", icon: FormatListNumberedIcon }, +]; From 0be3e48942116424b89067fd82f7e61b090da61e Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Sun, 31 May 2026 23:29:29 -0400 Subject: [PATCH 04/57] add keyboard handlers and utils --- .../utils/keyboard/keyboardHotkeyHandlers.ts | 64 +++++ .../utils/keyboard/keyboardListHandlers.ts | 255 ++++++++++++++++++ .../utils/keyboard/keyboardSelectionUtils.ts | 69 +++++ .../keyboard/richTextKeyboardHandlers.ts | 32 +++ 4 files changed, 420 insertions(+) create mode 100644 apps/frontend/src/components/RichTextEditor/utils/keyboard/keyboardHotkeyHandlers.ts create mode 100644 apps/frontend/src/components/RichTextEditor/utils/keyboard/keyboardListHandlers.ts create mode 100644 apps/frontend/src/components/RichTextEditor/utils/keyboard/keyboardSelectionUtils.ts create mode 100644 apps/frontend/src/components/RichTextEditor/utils/keyboard/richTextKeyboardHandlers.ts diff --git a/apps/frontend/src/components/RichTextEditor/utils/keyboard/keyboardHotkeyHandlers.ts b/apps/frontend/src/components/RichTextEditor/utils/keyboard/keyboardHotkeyHandlers.ts new file mode 100644 index 000000000..e359c7b1c --- /dev/null +++ b/apps/frontend/src/components/RichTextEditor/utils/keyboard/keyboardHotkeyHandlers.ts @@ -0,0 +1,64 @@ +import type { KeyboardEvent } from "react"; +import { HistoryEditor } from "slate-history"; + +import type { CustomEditor, MarkFormat } from "../../types"; +import { toggleMark } from "../editorTransforms"; + +const HOTKEY_MARK_FORMATS: Record = { + b: "bold", + i: "italic", + u: "underline", +}; + +const handleUndoRedoHotkeys = ( + event: KeyboardEvent, + editor: CustomEditor, + key: string +): boolean => { + const shouldUndo = key === "z" && !event.shiftKey; + + if (shouldUndo) { + event.preventDefault(); + HistoryEditor.undo(editor); + return true; + } + + const shouldRedo = key === "y" || (key === "z" && event.shiftKey); + + if (!shouldRedo) { + return false; + } + + event.preventDefault(); + HistoryEditor.redo(editor); + return true; +}; + +const handleMarkHotkeys = (event: KeyboardEvent, editor: CustomEditor, key: string): boolean => { + const format = HOTKEY_MARK_FORMATS[key]; + + if (!format) { + return false; + } + + event.preventDefault(); + toggleMark(editor, format); + return true; +}; + +/** + * Handles editor keyboard shortcuts that require Ctrl or Command. + */ +export const handleModifierHotkeys = (event: KeyboardEvent, editor: CustomEditor): boolean => { + if (!event.ctrlKey && !event.metaKey) { + return false; + } + + const key = event.key.toLowerCase(); + + if (handleUndoRedoHotkeys(event, editor, key)) { + return true; + } + + return handleMarkHotkeys(event, editor, key); +}; diff --git a/apps/frontend/src/components/RichTextEditor/utils/keyboard/keyboardListHandlers.ts b/apps/frontend/src/components/RichTextEditor/utils/keyboard/keyboardListHandlers.ts new file mode 100644 index 000000000..420ee7743 --- /dev/null +++ b/apps/frontend/src/components/RichTextEditor/utils/keyboard/keyboardListHandlers.ts @@ -0,0 +1,255 @@ +import type { KeyboardEvent } from "react"; +import { Editor, Element, Node, Point, Range, Transforms } from "slate"; + +import type { CustomEditor, ListFormat } from "../../types"; +import { isListElement } from "../editorGuards"; +import { toggleBlock } from "../editorTransforms"; + +import { + getCollapsedSelection, + getCurrentListFormat, + getFirstElementEntry, + getPreviousSiblingPath, + INDENTATION, + removeCharactersBeforeCursor, +} from "./keyboardSelectionUtils"; + +const getIndentationDistanceToRemove = (textBeforeCursor: string): number => { + if (textBeforeCursor.endsWith(INDENTATION)) { + return INDENTATION.length; + } + + if (textBeforeCursor.endsWith(" ")) { + return 1; + } + + return 0; +}; + +const removeParagraphIndentation = (editor: CustomEditor, selection: Range): void => { + const paragraphEntry = getFirstElementEntry(editor, "paragraph"); + + if (!paragraphEntry) { + return; + } + + const [, paragraphPath] = paragraphEntry; + const paragraphStart = Editor.start(editor, paragraphPath); + const rangeBeforeCursor: Range = { anchor: paragraphStart, focus: selection.focus }; + const textBeforeCursor = Editor.string(editor, rangeBeforeCursor); + const indentationDistance = getIndentationDistanceToRemove(textBeforeCursor); + + if (indentationDistance === 0) { + return; + } + + removeCharactersBeforeCursor(editor, indentationDistance); +}; + +const outdentListItem = (editor: CustomEditor): boolean => { + const listItemEntry = getFirstElementEntry(editor, "list-item"); + + if (!listItemEntry) { + return false; + } + + toggleBlock(editor, getCurrentListFormat(editor)); + return true; +}; + +/** + * Handles Tab and Shift+Tab indentation behavior. + */ +export const handleTabKey = (event: KeyboardEvent, editor: CustomEditor): boolean => { + if (event.key !== "Tab") { + return false; + } + + event.preventDefault(); + + if (!event.shiftKey) { + Transforms.insertText(editor, INDENTATION); + return true; + } + + const selection = getCollapsedSelection(editor); + + if (!selection) { + return true; + } + + if (outdentListItem(editor)) { + return true; + } + + removeParagraphIndentation(editor, selection); + return true; +}; + +const isFirstListItem = (listItemPath: number[]): boolean => + listItemPath[listItemPath.length - 1] === 0; + +/** + * Exits a list when Enter is pressed on an empty list item that is not first. + */ +export const handleEnterOnEmptyListItem = (event: KeyboardEvent, editor: CustomEditor): boolean => { + if (event.key !== "Enter") { + return false; + } + + const selection = getCollapsedSelection(editor); + + if (!selection) { + return false; + } + + const listItemEntry = getFirstElementEntry(editor, "list-item"); + + if (!listItemEntry) { + return false; + } + + const [listItemNode, listItemPath] = listItemEntry; + const listItemIsEmpty = Node.string(listItemNode) === ""; + const shouldExitList = listItemIsEmpty && !isFirstListItem(listItemPath); + + if (!shouldExitList) { + return false; + } + + event.preventDefault(); + toggleBlock(editor, getCurrentListFormat(editor)); + return true; +}; + +const moveCursorToEndOfPreviousList = ( + event: KeyboardEvent, + editor: CustomEditor, + selection: Range +): boolean => { + const paragraphEntry = getFirstElementEntry(editor, "paragraph"); + + if (!paragraphEntry) { + return false; + } + + const [, paragraphPath] = paragraphEntry; + const paragraphStart = Editor.start(editor, paragraphPath); + + if (!Point.equals(selection.focus, paragraphStart)) { + return false; + } + + const previousPath = getPreviousSiblingPath(paragraphPath); + + if (!previousPath || !Node.has(editor, previousPath)) { + return false; + } + + const previousNode = Node.get(editor, previousPath); + + if (!isListElement(previousNode)) { + return false; + } + + event.preventDefault(); + const lastListItemPath = [...previousPath, previousNode.children.length - 1]; + Transforms.select(editor, Editor.end(editor, lastListItemPath)); + return true; +}; + +const convertListItemToParagraph = ( + event: KeyboardEvent, + editor: CustomEditor, + selection: Range +): boolean => { + const listItemEntry = getFirstElementEntry(editor, "list-item"); + + if (!listItemEntry) { + return false; + } + + const [, listItemPath] = listItemEntry; + const listItemStart = Editor.start(editor, listItemPath); + + if (!Point.equals(selection.focus, listItemStart)) { + return false; + } + + event.preventDefault(); + toggleBlock(editor, getCurrentListFormat(editor)); + return true; +}; + +/** + * Handles Backspace list boundary behavior. + */ +export const handleBackspaceKey = (event: KeyboardEvent, editor: CustomEditor): boolean => { + if (event.key !== "Backspace") { + return false; + } + + const selection = getCollapsedSelection(editor); + + if (!selection) { + return false; + } + + if (moveCursorToEndOfPreviousList(event, editor, selection)) { + return true; + } + + return convertListItemToParagraph(event, editor, selection); +}; + +const getMarkdownShortcutListFormat = (textBeforeCursor: string): ListFormat | null => { + if (textBeforeCursor === "-") { + return "bulleted-list"; + } + + if (textBeforeCursor === "1.") { + return "numbered-list"; + } + + return null; +}; + +/** + * Converts markdown list shortcuts into actual Slate list blocks. + */ +export const handleMarkdownListShortcut = (event: KeyboardEvent, editor: CustomEditor): boolean => { + if (event.key !== " ") { + return false; + } + + const selection = getCollapsedSelection(editor); + + if (!selection) { + return false; + } + + const [blockEntry] = Editor.nodes(editor, { + match: (node) => + Element.isElement(node) && Editor.isBlock(editor, node) && node.type === "paragraph", + }); + + if (!blockEntry) { + return false; + } + + const [, blockPath] = blockEntry; + const blockStart = Editor.start(editor, blockPath); + const rangeBeforeCursor: Range = { anchor: blockStart, focus: selection.focus }; + const textBeforeCursor = Editor.string(editor, rangeBeforeCursor); + const listFormat = getMarkdownShortcutListFormat(textBeforeCursor); + + if (!listFormat) { + return false; + } + + event.preventDefault(); + Transforms.select(editor, rangeBeforeCursor); + Transforms.delete(editor); + toggleBlock(editor, listFormat); + return true; +}; diff --git a/apps/frontend/src/components/RichTextEditor/utils/keyboard/keyboardSelectionUtils.ts b/apps/frontend/src/components/RichTextEditor/utils/keyboard/keyboardSelectionUtils.ts new file mode 100644 index 000000000..4a077674e --- /dev/null +++ b/apps/frontend/src/components/RichTextEditor/utils/keyboard/keyboardSelectionUtils.ts @@ -0,0 +1,69 @@ +import { Editor, Element, NodeEntry, Path, Range, Transforms } from "slate"; + +import type { BlockFormat, CustomEditor, CustomElement, ListFormat } from "../../types"; + +export const INDENTATION = " "; + +/** + * Returns the current selection only when it is collapsed to a single cursor point. + */ +export const getCollapsedSelection = (editor: CustomEditor): Range | null => { + const { selection } = editor; + + if (!selection || !Range.isCollapsed(selection)) { + return null; + } + + return selection; +}; + +/** + * Finds the first Slate element entry for the requested block type. + */ +export const getFirstElementEntry = ( + editor: CustomEditor, + type: BlockFormat +): NodeEntry | undefined => { + const [entry] = Editor.nodes(editor, { + match: (node) => Element.isElement(node) && node.type === type, + }); + + return entry as NodeEntry | undefined; +}; + +/** + * Determines the active list format for keyboard operations. + */ +export const getCurrentListFormat = (editor: CustomEditor): ListFormat => { + const bulletListEntry = getFirstElementEntry(editor, "bulleted-list"); + + if (bulletListEntry) { + return "bulleted-list"; + } + + return "numbered-list"; +}; + +/** + * Deletes a number of characters immediately before the cursor. + */ +export const removeCharactersBeforeCursor = (editor: CustomEditor, distance: number): void => { + Transforms.delete(editor, { + unit: "character", + reverse: true, + distance, + }); +}; + +/** + * Returns the path for the previous sibling of a Slate path when it exists. + */ +export const getPreviousSiblingPath = (path: Path): Path | null => { + const currentIndex = path[path.length - 1]; + + if (currentIndex <= 0) { + return null; + } + + return [...path.slice(0, -1), currentIndex - 1]; +}; diff --git a/apps/frontend/src/components/RichTextEditor/utils/keyboard/richTextKeyboardHandlers.ts b/apps/frontend/src/components/RichTextEditor/utils/keyboard/richTextKeyboardHandlers.ts new file mode 100644 index 000000000..33a28474e --- /dev/null +++ b/apps/frontend/src/components/RichTextEditor/utils/keyboard/richTextKeyboardHandlers.ts @@ -0,0 +1,32 @@ +import type { KeyboardEvent } from "react"; + +import type { CustomEditor } from "../../types"; + +import { handleModifierHotkeys } from "./keyboardHotkeyHandlers"; +import { + handleBackspaceKey, + handleEnterOnEmptyListItem, + handleMarkdownListShortcut, + handleTabKey, +} from "./keyboardListHandlers"; + +type KeyboardHandler = (event: KeyboardEvent, editor: CustomEditor) => boolean; + +/** + * Ordered keyboard behaviors for the rich text editor. The first handler that + * returns true owns the event. + */ +export const RICH_TEXT_KEYBOARD_HANDLERS: KeyboardHandler[] = [ + handleTabKey, + handleEnterOnEmptyListItem, + handleBackspaceKey, + handleMarkdownListShortcut, + handleModifierHotkeys, +]; + +/** + * Runs the configured keyboard handlers for a Slate editor instance. + */ +export const handleRichTextEditorKeyDown = (event: KeyboardEvent, editor: CustomEditor): void => { + RICH_TEXT_KEYBOARD_HANDLERS.some((handler) => handler(event, editor)); +}; From a8cdc428e5652f9b9ac8ea4cdbf3a3957964fa83 Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Sun, 31 May 2026 23:43:22 -0400 Subject: [PATCH 05/57] add markdown handlers and utils --- .../utils/markdown/markdownDeserializer.ts | 126 +++++++++++++++ .../utils/markdown/markdownInlineParser.ts | 148 ++++++++++++++++++ .../utils/markdown/markdownSerializer.ts | 72 +++++++++ .../RichTextEditor/utils/plainTextUtils.ts | 29 ++++ 4 files changed, 375 insertions(+) create mode 100644 apps/frontend/src/components/RichTextEditor/utils/markdown/markdownDeserializer.ts create mode 100644 apps/frontend/src/components/RichTextEditor/utils/markdown/markdownInlineParser.ts create mode 100644 apps/frontend/src/components/RichTextEditor/utils/markdown/markdownSerializer.ts create mode 100644 apps/frontend/src/components/RichTextEditor/utils/plainTextUtils.ts diff --git a/apps/frontend/src/components/RichTextEditor/utils/markdown/markdownDeserializer.ts b/apps/frontend/src/components/RichTextEditor/utils/markdown/markdownDeserializer.ts new file mode 100644 index 000000000..2c11d9153 --- /dev/null +++ b/apps/frontend/src/components/RichTextEditor/utils/markdown/markdownDeserializer.ts @@ -0,0 +1,126 @@ +import type { Descendant } from "slate"; + +import { createEmptyDocument } from "../documentUtils"; +import { createListItem } from "../elementFactory"; +import type { ListFormat, ListItemElement } from "../types"; + +import { parseMarkdownInline } from "./markdownInlineParser"; + +type MarkdownBlockResult = { + nodes: Descendant[]; + nextLineIndex: number; +}; + +type MarkdownListDefinition = { + format: ListFormat; + pattern: RegExp; +}; + +const BULLETED_LIST_DEFINITION: MarkdownListDefinition = { + format: "bulleted-list", + pattern: /^[-*]\s+(.+)$/, +}; + +const NUMBERED_LIST_DEFINITION: MarkdownListDefinition = { + format: "numbered-list", + pattern: /^\d+\.\s+(.+)$/, +}; + +const getListItemContent = (line: string, pattern: RegExp): string | null => { + const match = line.match(pattern); + + if (!match) { + return null; + } + + return match[1]; +}; + +const findListDefinition = (line: string): MarkdownListDefinition | null => { + if (BULLETED_LIST_DEFINITION.pattern.test(line)) { + return BULLETED_LIST_DEFINITION; + } + + if (NUMBERED_LIST_DEFINITION.pattern.test(line)) { + return NUMBERED_LIST_DEFINITION; + } + + return null; +}; + +const readMarkdownListBlock = ( + lines: string[], + startLineIndex: number, + listDefinition: MarkdownListDefinition +): MarkdownBlockResult => { + const items: ListItemElement[] = []; + let lineIndex = startLineIndex; + + while (lineIndex < lines.length) { + const itemContent = getListItemContent(lines[lineIndex], listDefinition.pattern); + + if (itemContent === null) { + break; + } + + items.push(createListItem(parseMarkdownInline(itemContent))); + lineIndex += 1; + } + + return { + nodes: [{ type: listDefinition.format, children: items }], + nextLineIndex: lineIndex, + }; +}; + +const readMarkdownBlock = (lines: string[], lineIndex: number): MarkdownBlockResult => { + const line = lines[lineIndex]; + const listDefinition = findListDefinition(line); + + if (listDefinition) { + return readMarkdownListBlock(lines, lineIndex, listDefinition); + } + + if (line.trim() === "") { + return { + nodes: [], + nextLineIndex: lineIndex + 1, + }; + } + + return { + nodes: [{ type: "paragraph", children: parseMarkdownInline(line) }], + nextLineIndex: lineIndex + 1, + }; +}; + +const readMarkdownDocument = (lines: string[]): Descendant[] => { + const nodes: Descendant[] = []; + let lineIndex = 0; + + while (lineIndex < lines.length) { + const block = readMarkdownBlock(lines, lineIndex); + + nodes.push(...block.nodes); + lineIndex = block.nextLineIndex; + } + + return nodes; +}; + +/** + * Converts stored markdown rich-text content into a Slate value. + */ +export const deserializeFromMarkdown = (content: string): Descendant[] => { + if (!content.trim()) { + return createEmptyDocument(); + } + + const nodes = readMarkdownDocument(content.split(/\r?\n/)); + + if (nodes.length > 0) { + return nodes; + } + + return createEmptyDocument(); +}; diff --git a/apps/frontend/src/components/RichTextEditor/utils/markdown/markdownInlineParser.ts b/apps/frontend/src/components/RichTextEditor/utils/markdown/markdownInlineParser.ts new file mode 100644 index 000000000..91d7e717f --- /dev/null +++ b/apps/frontend/src/components/RichTextEditor/utils/markdown/markdownInlineParser.ts @@ -0,0 +1,148 @@ +import type { FormattedText, TextMarks } from "../../types"; +import { ensureTextChildren } from "../documentUtils"; + +type MarkdownTokenResult = { + nodes: FormattedText[]; + consumedText: string; +}; + +type MarkdownTokenParser = (text: string, marks: TextMarks) => MarkdownTokenResult | null; + +const BOLD_MARKDOWN_PATTERN = /^\*\*(.+?)\*\*/; +const ITALIC_ASTERISK_MARKDOWN_PATTERN = /^\*(.+?)\*/; +const ITALIC_UNDERSCORE_MARKDOWN_PATTERN = /^_(.+?)_/; +const UNDERLINE_MARKDOWN_PATTERN = /^(.+?)<\/u>/; +const PLAIN_TEXT_MARKDOWN_PATTERN = /^[^*_<\\]+/; + +/** + * Returns true when two text mark objects represent the same formatting state. + */ +export const marksAreEqual = (firstMarks: TextMarks, secondMarks: TextMarks): boolean => + firstMarks.bold === secondMarks.bold && + firstMarks.italic === secondMarks.italic && + firstMarks.underline === secondMarks.underline; + +const getTextMarks = ({ bold, italic, underline }: FormattedText): TextMarks => ({ + bold, + italic, + underline, +}); + +/** + * Appends text to a target collection and merges adjacent nodes with the same marks. + */ +export const appendFormattedText = (target: FormattedText[], nextText: FormattedText): void => { + const previousText = target[target.length - 1]; + + if (!previousText || !marksAreEqual(getTextMarks(previousText), getTextMarks(nextText))) { + target.push(nextText); + return; + } + + previousText.text += nextText.text; +}; + +const appendFormattedTexts = (target: FormattedText[], nextTexts: FormattedText[]): void => { + nextTexts.forEach((nextText) => appendFormattedText(target, nextText)); +}; + +const createMarkedTokenResult = ( + match: RegExpExecArray | null, + marks: TextMarks, + addedMarks: TextMarks +): MarkdownTokenResult | null => { + if (!match) { + return null; + } + + return { + nodes: parseMarkdownInline(match[1], { ...marks, ...addedMarks }), + consumedText: match[0], + }; +}; + +const parseBoldMarkdownToken: MarkdownTokenParser = (text, marks) => + createMarkedTokenResult(BOLD_MARKDOWN_PATTERN.exec(text), marks, { bold: true }); + +const parseItalicMarkdownToken: MarkdownTokenParser = (text, marks) => { + const asteriskMatch = ITALIC_ASTERISK_MARKDOWN_PATTERN.exec(text); + + if (asteriskMatch) { + return createMarkedTokenResult(asteriskMatch, marks, { italic: true }); + } + + return createMarkedTokenResult(ITALIC_UNDERSCORE_MARKDOWN_PATTERN.exec(text), marks, { + italic: true, + }); +}; + +const parseUnderlineMarkdownToken: MarkdownTokenParser = (text, marks) => + createMarkedTokenResult(UNDERLINE_MARKDOWN_PATTERN.exec(text), marks, { underline: true }); + +const parsePlainMarkdownToken: MarkdownTokenParser = (text, marks) => { + const plainTextMatch = PLAIN_TEXT_MARKDOWN_PATTERN.exec(text); + + if (plainTextMatch) { + return { + nodes: [{ text: plainTextMatch[0].replace(/\\([*_\\])/g, "$1"), ...marks }], + consumedText: plainTextMatch[0], + }; + } + + const fallbackMatch = /^./.exec(text); + + if (!fallbackMatch) { + return null; + } + + return { + nodes: [{ text: fallbackMatch[0].replace(/\\([*_\\])/g, "$1"), ...marks }], + consumedText: fallbackMatch[0], + }; +}; + +const MARKDOWN_TOKEN_PARSERS: MarkdownTokenParser[] = [ + parseBoldMarkdownToken, + parseItalicMarkdownToken, + parseUnderlineMarkdownToken, + parsePlainMarkdownToken, +]; + +const parseNextMarkdownToken = (text: string, marks: TextMarks): MarkdownTokenResult => { + const result = MARKDOWN_TOKEN_PARSERS.reduce((match, parser) => { + if (match) { + return match; + } + + return parser(text, marks); + }, null); + + if (result) { + return result; + } + + return { + nodes: [{ text: text[0] ?? "", ...marks }], + consumedText: text[0] ?? "", + }; +}; + +/** + * Parses inline markdown into Slate text nodes using the editor's supported mark subset. + */ +export const parseMarkdownInline = (text: string, marks: TextMarks = {}): FormattedText[] => { + if (!text) { + return []; + } + + const result: FormattedText[] = []; + let remainingText = text; + + while (remainingText.length > 0) { + const nextToken = parseNextMarkdownToken(remainingText, marks); + appendFormattedTexts(result, nextToken.nodes); + remainingText = remainingText.slice(nextToken.consumedText.length); + } + + return ensureTextChildren(result); +}; diff --git a/apps/frontend/src/components/RichTextEditor/utils/markdown/markdownSerializer.ts b/apps/frontend/src/components/RichTextEditor/utils/markdown/markdownSerializer.ts new file mode 100644 index 000000000..479b24fe3 --- /dev/null +++ b/apps/frontend/src/components/RichTextEditor/utils/markdown/markdownSerializer.ts @@ -0,0 +1,72 @@ +import { Descendant, Element, Text } from "slate"; + +import type { FormattedText, ListItemElement, TextMarks } from "../../types"; + +/** + * Escapes markdown control characters that are supported by this editor. + */ +export const escapeMarkdownText = (text: string): string => + text.replace(/\\/g, "\\\\").replace(/\*/g, "\\*").replace(/_/g, "\\_"); + +/** + * Applies markdown-compatible formatting marks to a plain text value. + */ +export const applyMarkdownMarks = (text: string, marks: TextMarks): string => { + let markedText = escapeMarkdownText(text); + + if (marks.bold) { + markedText = `**${markedText}**`; + } + + if (marks.italic) { + markedText = `_${markedText}_`; + } + + if (marks.underline) { + markedText = `${markedText}`; + } + + return markedText; +}; + +const serializeMarkdownText = ({ text, bold, italic, underline }: FormattedText): string => + applyMarkdownMarks(text, { bold, italic, underline }); + +const serializeTextChildren = (children: FormattedText[]): string => + children.map(serializeMarkdownText).join(""); + +const serializeBulletListItem = (item: ListItemElement): string => + `- ${serializeTextChildren(item.children)}`; + +const serializeNumberedListItem = (item: ListItemElement, itemIndex: number): string => + `${itemIndex + 1}. ${serializeTextChildren(item.children)}`; + +const serializeMarkdownBlock = (node: Descendant): string => { + if (Text.isText(node)) { + return node.text; + } + + if (!Element.isElement(node)) { + return ""; + } + + if (node.type === "paragraph") { + return serializeTextChildren(node.children); + } + + if (node.type === "bulleted-list") { + return node.children.map(serializeBulletListItem).join("\n"); + } + + if (node.type === "numbered-list") { + return node.children.map(serializeNumberedListItem).join("\n"); + } + + return ""; +}; + +/** + * Converts a Slate rich-text value into the markdown subset used for storage. + */ +export const serializeToMarkdown = (nodes: Descendant[]): string => + nodes.map(serializeMarkdownBlock).filter(Boolean).join("\n\n").trimEnd(); diff --git a/apps/frontend/src/components/RichTextEditor/utils/plainTextUtils.ts b/apps/frontend/src/components/RichTextEditor/utils/plainTextUtils.ts new file mode 100644 index 000000000..4aeffa308 --- /dev/null +++ b/apps/frontend/src/components/RichTextEditor/utils/plainTextUtils.ts @@ -0,0 +1,29 @@ +/** + * Removes the markdown subset supported by this editor and returns display text. + */ +export const removeMarkdownSyntax = (content: string): string => + content + .replace(/\*\*(.+?)\*\*/gs, "$1") + .replace(/\*(.+?)\*/gs, "$1") + .replace(/_(.+?)_/gs, "$1") + .replace(/(.+?)<\/u>/gs, "$1") + .replace(/^[-*] /gm, "") + .replace(/^\d+\. /gm, "") + .replace(/\\([*_\\])/g, "$1") + .trim(); + +/** + * Gets user-visible text from either markdown content or legacy HTML content. + */ +export const getPlainText = (content: string): string => { + if (!content.trim()) { + return ""; + } + + return removeMarkdownSyntax(content); +}; + +/** + * Gets the user-visible text length from stored rich-text content. + */ +export const getPlainTextLength = (content: string): number => getPlainText(content).length; From 72b705b1b2fd2f467cd83d60062254fc5bb099d4 Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Sun, 31 May 2026 23:45:44 -0400 Subject: [PATCH 06/57] add: EditorLeaf to render Slate leaf nodes --- .../components/RichTextEditor/EditorLeaf.tsx | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 apps/frontend/src/components/RichTextEditor/EditorLeaf.tsx diff --git a/apps/frontend/src/components/RichTextEditor/EditorLeaf.tsx b/apps/frontend/src/components/RichTextEditor/EditorLeaf.tsx new file mode 100644 index 000000000..8d3040927 --- /dev/null +++ b/apps/frontend/src/components/RichTextEditor/EditorLeaf.tsx @@ -0,0 +1,41 @@ +import type { ReactElement, ReactNode } from "react"; +import type { RenderLeafProps } from "slate-react"; + +import type { MarkFormat } from "./types"; + +type LeafRenderer = (children: ReactNode) => ReactElement; + +type LeafMarkRenderer = { + format: MarkFormat; + render: LeafRenderer; +}; + +const LEAF_MARK_RENDERERS: LeafMarkRenderer[] = [ + { + format: "bold", + render: (children) => {children}, + }, + { + format: "italic", + render: (children) => {children}, + }, + { + format: "underline", + render: (children) => {children}, + }, +]; + +const renderMarkedContent = ({ children, leaf }: RenderLeafProps): ReactNode => + LEAF_MARK_RENDERERS.reduce((content, markRenderer) => { + if (!leaf[markRenderer.format]) { + return content; + } + + return markRenderer.render(content); + }, children); + +const EditorLeaf = ({ attributes, children, leaf, text }: RenderLeafProps): ReactElement => ( + {renderMarkedContent({ attributes, children, leaf, text })} +); + +export default EditorLeaf; From a85562f410c4b0b788e469688e2124bfb3c2887d Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Sun, 31 May 2026 23:46:22 -0400 Subject: [PATCH 07/57] add: EditorElement for for rendering element types --- .../RichTextEditor/EditorElement.tsx | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 apps/frontend/src/components/RichTextEditor/EditorElement.tsx diff --git a/apps/frontend/src/components/RichTextEditor/EditorElement.tsx b/apps/frontend/src/components/RichTextEditor/EditorElement.tsx new file mode 100644 index 000000000..5019c2716 --- /dev/null +++ b/apps/frontend/src/components/RichTextEditor/EditorElement.tsx @@ -0,0 +1,32 @@ +import type { ReactElement } from "react"; +import type { RenderElementProps } from "slate-react"; + +import type { BlockFormat } from "./types"; + +type ElementRenderer = (props: RenderElementProps) => ReactElement; + +const renderParagraph: ElementRenderer = ({ attributes, children }) => ( +

{children}

+); + +const ELEMENT_RENDERERS: Record = { + paragraph: renderParagraph, + "bulleted-list": ({ attributes, children }) =>
    {children}
, + "numbered-list": ({ attributes, children }) =>
    {children}
, + "list-item": ({ attributes, children }) =>
  • {children}
  • , +}; + +const getElementRenderer = (type: BlockFormat): ElementRenderer => { + const renderer = ELEMENT_RENDERERS[type]; + + if (!renderer) { + return renderParagraph; + } + + return renderer; +}; + +const EditorElement = (props: RenderElementProps): ReactElement => + getElementRenderer(props.element.type)(props); + +export default EditorElement; From c7b9ae1047519c4ba3067df61775a2b7d171c944 Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Sun, 31 May 2026 23:47:04 -0400 Subject: [PATCH 08/57] added hook for handling editor keydown --- .../hooks/useRichTextKeyboardHandler.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 apps/frontend/src/components/RichTextEditor/hooks/useRichTextKeyboardHandler.ts diff --git a/apps/frontend/src/components/RichTextEditor/hooks/useRichTextKeyboardHandler.ts b/apps/frontend/src/components/RichTextEditor/hooks/useRichTextKeyboardHandler.ts new file mode 100644 index 000000000..e3de41bf4 --- /dev/null +++ b/apps/frontend/src/components/RichTextEditor/hooks/useRichTextKeyboardHandler.ts @@ -0,0 +1,18 @@ +import { useCallback } from "react"; +import type { KeyboardEvent } from "react"; + +import type { CustomEditor } from "../types"; +import { handleRichTextEditorKeyDown } from "../utils/keyboard/richTextKeyboardHandlers"; + +/** + * Creates the memoized keyboard event handler used by the Slate editable area. + */ +export const useRichTextKeyboardHandler = ( + editor: CustomEditor +): ((event: KeyboardEvent) => void) => + useCallback( + (event: KeyboardEvent): void => { + handleRichTextEditorKeyDown(event, editor); + }, + [editor] + ); From f0f2bd3b66bbaa0887ec9a83f0efffad565559ad Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Sun, 31 May 2026 23:49:42 -0400 Subject: [PATCH 09/57] add: document utilities for Slate editor and update markdown parser --- .../RichTextEditor/utils/documentUtils.ts | 40 +++++++++++++++++++ .../components/RichTextEditor/utils/index.ts | 14 +++++++ .../utils/markdown/markdownInlineParser.ts | 4 +- 3 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 apps/frontend/src/components/RichTextEditor/utils/documentUtils.ts create mode 100644 apps/frontend/src/components/RichTextEditor/utils/index.ts diff --git a/apps/frontend/src/components/RichTextEditor/utils/documentUtils.ts b/apps/frontend/src/components/RichTextEditor/utils/documentUtils.ts new file mode 100644 index 000000000..e4a1b0dde --- /dev/null +++ b/apps/frontend/src/components/RichTextEditor/utils/documentUtils.ts @@ -0,0 +1,40 @@ +import { Descendant, Element, Text } from "slate"; + +import type { FormattedText, ParagraphElement } from "../types"; + +const EMPTY_TEXT_NODE: FormattedText = { text: "" }; + +/** + * Creates the default Slate value used when no saved rich-text content exists. + */ +export const createEmptyDocument = (): ParagraphElement[] => [ + { type: "paragraph", children: [{ ...EMPTY_TEXT_NODE }] }, +]; + +/** + * Ensures Slate element children always contain at least one text node. + */ +export const normalizeTextChildren = (children: FormattedText[]): FormattedText[] => { + if (children.length > 0) { + return children; + } + + return [{ ...EMPTY_TEXT_NODE }]; +}; + +const nodeHasText = (node: Descendant): boolean => { + if (Text.isText(node)) { + return Boolean(node.text.trim()); + } + + if (!Element.isElement(node)) { + return false; + } + + return node.children.some((childNode: Descendant) => nodeHasText(childNode)); +}; + +/** + * Returns true when a Slate document contains no non-whitespace text. + */ +export const isEditorEmpty = (nodes: Descendant[]): boolean => !nodes.some(nodeHasText); diff --git a/apps/frontend/src/components/RichTextEditor/utils/index.ts b/apps/frontend/src/components/RichTextEditor/utils/index.ts new file mode 100644 index 000000000..2e0863a3c --- /dev/null +++ b/apps/frontend/src/components/RichTextEditor/utils/index.ts @@ -0,0 +1,14 @@ +export * from "./documentUtils"; +export * from "./editorGuards"; +export * from "./editorTransforms"; +export * from "./elementFactory"; +export * from "./plainTextUtils"; + +export * from "./keyboard/richTextKeyboardHandlers"; +export * from "./keyboard/keyboardHotkeyHandlers"; +export * from "./keyboard/keyboardListHandlers"; +export * from "./keyboard/keyboardSelectionUtils"; + +export * from "./markdown/markdownSerializer"; +export * from "./markdown/markdownDeserializer"; +export * from "./markdown/markdownInlineParser"; diff --git a/apps/frontend/src/components/RichTextEditor/utils/markdown/markdownInlineParser.ts b/apps/frontend/src/components/RichTextEditor/utils/markdown/markdownInlineParser.ts index 91d7e717f..a5d47d513 100644 --- a/apps/frontend/src/components/RichTextEditor/utils/markdown/markdownInlineParser.ts +++ b/apps/frontend/src/components/RichTextEditor/utils/markdown/markdownInlineParser.ts @@ -1,5 +1,5 @@ import type { FormattedText, TextMarks } from "../../types"; -import { ensureTextChildren } from "../documentUtils"; +import { normalizeTextChildren } from "../documentUtils"; type MarkdownTokenResult = { nodes: FormattedText[]; @@ -144,5 +144,5 @@ export const parseMarkdownInline = (text: string, marks: TextMarks = {}): Format remainingText = remainingText.slice(nextToken.consumedText.length); } - return ensureTextChildren(result); + return normalizeTextChildren(result); }; From d7f3536273c41b18faf75086bd0c5c2a142f6811 Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Sun, 31 May 2026 23:49:57 -0400 Subject: [PATCH 10/57] fix: documentation wording --- .../src/components/RichTextEditor/utils/plainTextUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/frontend/src/components/RichTextEditor/utils/plainTextUtils.ts b/apps/frontend/src/components/RichTextEditor/utils/plainTextUtils.ts index 4aeffa308..64a6e1685 100644 --- a/apps/frontend/src/components/RichTextEditor/utils/plainTextUtils.ts +++ b/apps/frontend/src/components/RichTextEditor/utils/plainTextUtils.ts @@ -13,7 +13,7 @@ export const removeMarkdownSyntax = (content: string): string => .trim(); /** - * Gets user-visible text from either markdown content or legacy HTML content. + * Gets user-visible text from stored markdown rich-text content. */ export const getPlainText = (content: string): string => { if (!content.trim()) { From ed6e0bfccd1031d792a78efb6db52558dfffb7e9 Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Sun, 31 May 2026 23:50:34 -0400 Subject: [PATCH 11/57] add: useRichTextEditor hook for Slate editor handling --- .../hooks/useRichTextEditor.tsx | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 apps/frontend/src/components/RichTextEditor/hooks/useRichTextEditor.tsx diff --git a/apps/frontend/src/components/RichTextEditor/hooks/useRichTextEditor.tsx b/apps/frontend/src/components/RichTextEditor/hooks/useRichTextEditor.tsx new file mode 100644 index 000000000..efdd9a23c --- /dev/null +++ b/apps/frontend/src/components/RichTextEditor/hooks/useRichTextEditor.tsx @@ -0,0 +1,83 @@ +import { useCallback, useState } from "react"; +import type { ReactElement } from "react"; +import { createEditor, Node, Transforms } from "slate"; +import type { Descendant } from "slate"; +import { HistoryEditor } from "slate-history"; +import type { RenderElementProps, RenderLeafProps } from "slate-react"; + +import EditorElement from "../EditorElement"; +import EditorLeaf from "../EditorLeaf"; +import { createEmptyDocument } from "../utils/documentUtils"; +import { withCustomEditor } from "../utils/editorTransforms"; +import { deserializeFromMarkdown } from "../utils/markdown/markdownDeserializer"; +import { serializeToMarkdown } from "../utils/markdown/markdownSerializer"; + +import { useRichTextKeyboardHandler } from "./useRichTextKeyboardHandler"; + +type UseRichTextEditorParams = { + value: string; + onChange: (value: string) => void; + onTextLengthChange?: (length: number) => void; +}; + +type UseRichTextEditorResult = { + editor: ReturnType; + initialValue: Descendant[]; + handleChange: (newValue: Descendant[]) => void; + handleKeyDown: ReturnType; + renderElement: (props: RenderElementProps) => ReactElement; + renderLeaf: (props: RenderLeafProps) => ReactElement; + reset: () => void; +}; + +/** + * Owns the Slate editor instance, initial value, serialization, render callbacks, + * and keyboard handler for the rich text editor component. + */ +export const useRichTextEditor = ({ + value, + onChange, + onTextLengthChange, +}: UseRichTextEditorParams): UseRichTextEditorResult => { + const [editor] = useState(() => withCustomEditor(createEditor())); + const [initialValue] = useState(() => deserializeFromMarkdown(value)); + + const handleChange = useCallback( + (newValue: Descendant[]): void => { + onChange(serializeToMarkdown(newValue)); + onTextLengthChange?.(Node.string(editor).length); + }, + [editor, onChange, onTextLengthChange] + ); + + const renderElement = useCallback( + (props: RenderElementProps): ReactElement => , + [] + ); + + const renderLeaf = useCallback( + (props: RenderLeafProps): ReactElement => , + [] + ); + + const handleKeyDown = useRichTextKeyboardHandler(editor); + + const reset = useCallback((): void => { + Transforms.deselect(editor); + editor.children = createEmptyDocument(); + if (HistoryEditor.isHistoryEditor(editor)) { + editor.history = { undos: [], redos: [] }; + } + editor.onChange(); + }, [editor]); + + return { + editor, + initialValue, + handleChange, + handleKeyDown, + renderElement, + renderLeaf, + reset, + }; +}; From 773ffa172306fc0c309c5fe7716de4c93ca6efd5 Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Sun, 31 May 2026 23:51:25 -0400 Subject: [PATCH 12/57] add: RichTextEditor component entry --- .../src/components/RichTextEditor/index.tsx | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 apps/frontend/src/components/RichTextEditor/index.tsx diff --git a/apps/frontend/src/components/RichTextEditor/index.tsx b/apps/frontend/src/components/RichTextEditor/index.tsx new file mode 100644 index 000000000..53b137c2e --- /dev/null +++ b/apps/frontend/src/components/RichTextEditor/index.tsx @@ -0,0 +1,122 @@ +import { Box, styled } from "@mui/material"; +import type { ReactElement } from "react"; +import { forwardRef, useImperativeHandle } from "react"; +import { Editable, Slate } from "slate-react"; + +import { useRichTextEditor } from "./hooks/useRichTextEditor"; +import Toolbar from "./Toolbar"; +import { createEmptyDocument } from "./utils/documentUtils"; + +export type RichTextEditorProps = { + value: string; + onChange: (value: string) => void; + onTextLengthChange?: (length: number) => void; + placeholder?: string; + disabled?: boolean; + "aria-label"?: string; + "data-testid"?: string; +}; + +export type RichTextEditorHandle = { + reset: () => void; +}; + +const StyledEditorWrapper = styled(Box)({ + marginTop: "24px", + border: "1px solid rgba(0, 0, 0, 0.23)", + borderRadius: "4px", + display: "flex", + flexDirection: "column", + width: "fit-content", + maxWidth: "100%", + boxSizing: "border-box", + "&:hover": { + borderColor: "rgba(0, 0, 0, 0.87)", + }, + "&:focus-within": { + borderColor: "#1976d2", + borderWidth: "2px", + }, +}); + +const StyledEditable = styled(Editable)({ + padding: "12px", + lineHeight: "25px", + width: "min(750px, calc(100vw - 150px))", + minWidth: "min(750px, calc(100vw - 150px))", + maxWidth: "min(1440px, 80vw)", + height: "min(375px, calc(100vh - 340px))", + minHeight: "clamp(100px, calc(100vh - 340px), 375px) !important", + maxHeight: "min(500px, calc(100vh - 340px))", + resize: "both", + overflowY: "auto", + overflowX: "hidden", + boxSizing: "border-box", + outline: "none", + "& [data-slate-placeholder]": { + top: "12px !important", + }, + "& p": { + margin: "0 0 4px 0", + "&:last-child": { marginBottom: 0 }, + }, + "& ul, & ol": { + margin: "0 0 4px 0", + paddingLeft: "24px", + "&:last-child": { marginBottom: 0 }, + }, + "& li": { + lineHeight: "25px", + }, +}); + +const EditorToolbar = ({ disabled }: { disabled: boolean }): ReactElement | null => { + if (disabled) { + return null; + } + + return ; +}; + +const RichTextEditor = forwardRef( + ( + { + value, + onChange, + onTextLengthChange, + placeholder, + disabled = false, + "aria-label": ariaLabel, + "data-testid": dataTestId, + }, + ref + ): ReactElement => { + const { editor, initialValue, handleChange, handleKeyDown, renderElement, renderLeaf, reset } = + useRichTextEditor({ value, onChange, onTextLengthChange }); + + useImperativeHandle(ref, () => ({ reset }), [reset]); + + return ( + + + + + + + ); + } +); + +RichTextEditor.displayName = "RichTextEditor"; + +export { createEmptyDocument }; + +export default RichTextEditor; From bf58a3f2f7e131a6b01f10a78b57293223170e06 Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Sun, 31 May 2026 23:51:45 -0400 Subject: [PATCH 13/57] created rich text viewer component --- .../src/components/RichTextViewer/index.tsx | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 apps/frontend/src/components/RichTextViewer/index.tsx diff --git a/apps/frontend/src/components/RichTextViewer/index.tsx b/apps/frontend/src/components/RichTextViewer/index.tsx new file mode 100644 index 000000000..1e1646a10 --- /dev/null +++ b/apps/frontend/src/components/RichTextViewer/index.tsx @@ -0,0 +1,31 @@ +import type { ReactElement } from "react"; +import ReactMarkdown from "react-markdown"; +import rehypeRaw from "rehype-raw"; +import remarkGfm from "remark-gfm"; + +const MARKDOWN_ALLOWED_ELEMENTS = ["p", "strong", "em", "u", "ul", "ol", "li"] as const; + +type RichTextViewerProps = { + content: string; + className?: string; +}; + +const RichTextViewer = ({ content, className }: RichTextViewerProps): ReactElement | null => { + if (!content.trim()) { + return null; + } + + return ( + + {content} + + ); +}; + +export default RichTextViewer; From 9fe4bfef74a185b8d8872a907bdb3100c03b1b62 Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Sun, 31 May 2026 23:54:06 -0400 Subject: [PATCH 14/57] update review form dialog to include rich text editor --- .../Questionnaire/ReviewFormDialog.test.tsx | 38 ++++++++-- .../Questionnaire/ReviewFormDialog.tsx | 71 +++++++------------ 2 files changed, 60 insertions(+), 49 deletions(-) diff --git a/apps/frontend/src/components/Questionnaire/ReviewFormDialog.test.tsx b/apps/frontend/src/components/Questionnaire/ReviewFormDialog.test.tsx index e33969c07..743e753d0 100644 --- a/apps/frontend/src/components/Questionnaire/ReviewFormDialog.test.tsx +++ b/apps/frontend/src/components/Questionnaire/ReviewFormDialog.test.tsx @@ -5,6 +5,36 @@ import { render, waitFor, within } from "../../test-utils"; import ReviewFormDialog from "./ReviewFormDialog"; +vi.mock("../RichTextEditor", () => ({ + default: ({ + value, + onChange, + onTextLengthChange, + "data-testid": dataTestId, + placeholder, + disabled, + }: { + value: string; + onChange: (v: string) => void; + onTextLengthChange?: (n: number) => void; + "data-testid"?: string; + placeholder?: string; + disabled?: boolean; + }) => ( +
    +