commit 3da453719cc68722b4828810e1c07926a6331318
parent 2d1af0ac64eac5b2bfb61f9e1939a6d798f9f451
Author: MTRNord <mtrnord1@gmail.com>
Date: Fri, 21 Apr 2023 19:28:36 +0200
feat: Add basic chat inputfield richtext editor
Diffstat:
14 files changed, 1833 insertions(+), 62 deletions(-)
diff --git a/package-lock.json b/package-lock.json
@@ -8,6 +8,7 @@
"name": "cetirizine",
"version": "0.1.0",
"dependencies": {
+ "@lexical/react": "^0.10.0",
"@reduxjs/toolkit": "^1.9.5",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
@@ -17,6 +18,8 @@
"@types/react": "^18.0.37",
"@types/react-dom": "^18.0.11",
"axios": "^1.3.5",
+ "lexical": "^0.10.0",
+ "lucide-react": "^0.171.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^8.0.5",
@@ -2811,6 +2814,242 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==",
"dev": true
},
+ "node_modules/@lexical/clipboard": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.10.0.tgz",
+ "integrity": "sha512-k1n93NQdTrGHFMQQ1NxD/G13uoTEBHKOqjgSAV3I3pQjG57zO51LsMjBxgR9BChVI1DotnQ/JQCbx2HCQkCeng==",
+ "dependencies": {
+ "@lexical/html": "0.10.0",
+ "@lexical/list": "0.10.0",
+ "@lexical/selection": "0.10.0",
+ "@lexical/utils": "0.10.0"
+ },
+ "peerDependencies": {
+ "lexical": "0.10.0"
+ }
+ },
+ "node_modules/@lexical/code": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@lexical/code/-/code-0.10.0.tgz",
+ "integrity": "sha512-ZEeoAtj/nXWmmN0Ol4lXSEAtxQozdDd/5I9P23Z4Leobg1YioZBDKwodM/TEITxrFme/cLgk3XCK1N2h2Noakw==",
+ "dependencies": {
+ "@lexical/utils": "0.10.0",
+ "prismjs": "^1.27.0"
+ },
+ "peerDependencies": {
+ "lexical": "0.10.0"
+ }
+ },
+ "node_modules/@lexical/dragon": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.10.0.tgz",
+ "integrity": "sha512-LBRkUW4NadFFQN9BIL3nz51AtPl1nSzRas61ob+hueSbovgr/+k9QGjLD1L7FIKnna2qzRlJjoI70Ll/V3fiCQ==",
+ "peerDependencies": {
+ "lexical": "0.10.0"
+ }
+ },
+ "node_modules/@lexical/hashtag": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.10.0.tgz",
+ "integrity": "sha512-jy+LssP4ABfwS+bT2KD+W6gWl+dsyFd8Ql27bsfkNoeyIBMeSLRBNBymkkUyZGKfXrbkzMUJoH7Dws/loHY5Ng==",
+ "dependencies": {
+ "@lexical/utils": "0.10.0"
+ },
+ "peerDependencies": {
+ "lexical": "0.10.0"
+ }
+ },
+ "node_modules/@lexical/history": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.10.0.tgz",
+ "integrity": "sha512-yu7FruEtOulZliE6nVgvC+GLVHhN15IcLVvKdYIi+QYzkOorKc6miVuGpLvJRHE7VtK0NMWb2ykyYz9gl5jW3Q==",
+ "dependencies": {
+ "@lexical/utils": "0.10.0"
+ },
+ "peerDependencies": {
+ "lexical": "0.10.0"
+ }
+ },
+ "node_modules/@lexical/html": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.10.0.tgz",
+ "integrity": "sha512-zxPbjojLfZXt4Sx6CMi0NzPUrTJG8McXaCsqPKK2GQQnkKfmXLh5QsK7YhofngBDDQOtqVYzTNhrxMWVP1Sm/A==",
+ "dependencies": {
+ "@lexical/selection": "0.10.0"
+ },
+ "peerDependencies": {
+ "lexical": "0.10.0"
+ }
+ },
+ "node_modules/@lexical/link": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.10.0.tgz",
+ "integrity": "sha512-LehZx9ruUR0UNZmNUMofrgwGkQo75X/yEAHQ/qFXt8Jz0D4g9fS6vsiSVn7BD4XrTdaf2kUaIe0VHScIZPjB0A==",
+ "dependencies": {
+ "@lexical/utils": "0.10.0"
+ },
+ "peerDependencies": {
+ "lexical": "0.10.0"
+ }
+ },
+ "node_modules/@lexical/list": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.10.0.tgz",
+ "integrity": "sha512-xoba6e4RUPIOhD0kJ0X1iI8SgOGDNWtyObd/UBbFfAQDx0VETj31Q7PepSPy9OEeuvnF1Nn9pvUeHyG7cNFuHw==",
+ "dependencies": {
+ "@lexical/utils": "0.10.0"
+ },
+ "peerDependencies": {
+ "lexical": "0.10.0"
+ }
+ },
+ "node_modules/@lexical/mark": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.10.0.tgz",
+ "integrity": "sha512-Xo8G8tADlxV9Es5hbca6MfZjOU5mkjZNdbRJ5dy4STNhtl3FcqbVGSAewFibzsKG3x/s8npFDY6nN/k/lkGaRg==",
+ "dependencies": {
+ "@lexical/utils": "0.10.0"
+ },
+ "peerDependencies": {
+ "lexical": "0.10.0"
+ }
+ },
+ "node_modules/@lexical/markdown": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.10.0.tgz",
+ "integrity": "sha512-P6XT8736DtZoTV6KfO0+FM9pkCbGGk0wCim1HIDc3v2S4r9+Pie/FgOOWSz4jKygjhs/6EYgYJmZKq7G4Fo5WA==",
+ "dependencies": {
+ "@lexical/code": "0.10.0",
+ "@lexical/link": "0.10.0",
+ "@lexical/list": "0.10.0",
+ "@lexical/rich-text": "0.10.0",
+ "@lexical/text": "0.10.0",
+ "@lexical/utils": "0.10.0"
+ },
+ "peerDependencies": {
+ "lexical": "0.10.0"
+ }
+ },
+ "node_modules/@lexical/offset": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@lexical/offset/-/offset-0.10.0.tgz",
+ "integrity": "sha512-vgVmoR7XMjFuQiO6GecLGM/gcNgJlJXitO5uCHARi2yjJDmmCQ07THu5xpfXKXnkJTZXn1VfWWmAGu72Q9fKNw==",
+ "peerDependencies": {
+ "lexical": "0.10.0"
+ }
+ },
+ "node_modules/@lexical/overflow": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.10.0.tgz",
+ "integrity": "sha512-8YfYhjwDGliGzYFyLVH4nCqrgR0p3+vAwEe2WI65HJnPW9TnxRkrak7QkFPTsefiTnb1gnRc+FajP9LfjdQ8YA==",
+ "peerDependencies": {
+ "lexical": "0.10.0"
+ }
+ },
+ "node_modules/@lexical/plain-text": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.10.0.tgz",
+ "integrity": "sha512-KcjQR+nHvXQDDRZ9bhcLOWfrY47OXcQc1YMx3otHbMPlEC+f/J75DTUyp+V5fry9X8gHDq0iHm+w8hqgh5ii1Q==",
+ "peerDependencies": {
+ "@lexical/clipboard": "0.10.0",
+ "@lexical/selection": "0.10.0",
+ "@lexical/utils": "0.10.0",
+ "lexical": "0.10.0"
+ }
+ },
+ "node_modules/@lexical/react": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.10.0.tgz",
+ "integrity": "sha512-7Ql/Y3FZSsPSCObT58CYRUd4tQzKL2U8B1xO0KXZBUbj+sO6gpNbc7/Y7MiZwCQzVNwP84j7mwkXQ1EnUBS52A==",
+ "dependencies": {
+ "@lexical/clipboard": "0.10.0",
+ "@lexical/code": "0.10.0",
+ "@lexical/dragon": "0.10.0",
+ "@lexical/hashtag": "0.10.0",
+ "@lexical/history": "0.10.0",
+ "@lexical/link": "0.10.0",
+ "@lexical/list": "0.10.0",
+ "@lexical/mark": "0.10.0",
+ "@lexical/markdown": "0.10.0",
+ "@lexical/overflow": "0.10.0",
+ "@lexical/plain-text": "0.10.0",
+ "@lexical/rich-text": "0.10.0",
+ "@lexical/selection": "0.10.0",
+ "@lexical/table": "0.10.0",
+ "@lexical/text": "0.10.0",
+ "@lexical/utils": "0.10.0",
+ "@lexical/yjs": "0.10.0",
+ "react-error-boundary": "^3.1.4"
+ },
+ "peerDependencies": {
+ "lexical": "0.10.0",
+ "react": ">=17.x",
+ "react-dom": ">=17.x"
+ }
+ },
+ "node_modules/@lexical/rich-text": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.10.0.tgz",
+ "integrity": "sha512-mkg+8h5qh09daCc8+PhzHjczSVhlTOkrr8S4oma89mfQZ/CODJYd0tOOd9y/8JaDrBddTqjd4xTjXwEEJzVA4g==",
+ "peerDependencies": {
+ "@lexical/clipboard": "0.10.0",
+ "@lexical/selection": "0.10.0",
+ "@lexical/utils": "0.10.0",
+ "lexical": "0.10.0"
+ }
+ },
+ "node_modules/@lexical/selection": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.10.0.tgz",
+ "integrity": "sha512-dziT1fb+/x8S3PUHI6BuF4DPML2njCDdyY7l9uTgzN9jGXxo9bzy+ySgRRN8IgjWQb8jkntAqkYIWTj4/3Jr7Q==",
+ "peerDependencies": {
+ "lexical": "0.10.0"
+ }
+ },
+ "node_modules/@lexical/table": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.10.0.tgz",
+ "integrity": "sha512-m0MBma4PNn5VV3yAGFK0iCTCZ5z/VBrZWLGxo3yPnY7FHgtUsDY84xYXziZGeixU+ehiQfsSPWVEGEC+EHFGTg==",
+ "dependencies": {
+ "@lexical/utils": "0.10.0"
+ },
+ "peerDependencies": {
+ "lexical": "0.10.0"
+ }
+ },
+ "node_modules/@lexical/text": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.10.0.tgz",
+ "integrity": "sha512-kR/V4KgCIUedjEbdPtIrvsvalETDbAPhCB7HA9MBALYw9FxjXlpYBqP7yY8AxWoYCXMqDtKRu/vmo/OMi3Z8Bw==",
+ "peerDependencies": {
+ "lexical": "0.10.0"
+ }
+ },
+ "node_modules/@lexical/utils": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.10.0.tgz",
+ "integrity": "sha512-HrRZBLxZhe5V8+kNH6VZP9pfhQdBwK76KYBjWpCaQWkmeeuLx8clY3O+SMjy1Pu/Pdh80qqtad8bdJ0l2CxMSQ==",
+ "dependencies": {
+ "@lexical/list": "0.10.0",
+ "@lexical/selection": "0.10.0",
+ "@lexical/table": "0.10.0"
+ },
+ "peerDependencies": {
+ "lexical": "0.10.0"
+ }
+ },
+ "node_modules/@lexical/yjs": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.10.0.tgz",
+ "integrity": "sha512-OV5yLl4XjfDKgRPSb1EyefU7c+MILKZrOzbPlv2xeTgIEd/sJiEszxvJlYsngLLN65wBDNKk90EcCWpH3KLz7A==",
+ "dependencies": {
+ "@lexical/offset": "0.10.0"
+ },
+ "peerDependencies": {
+ "lexical": "0.10.0",
+ "yjs": ">=13.5.22"
+ }
+ },
"node_modules/@mdx-js/react": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-2.3.0.tgz",
@@ -9304,6 +9543,16 @@
"unfetch": "^4.2.0"
}
},
+ "node_modules/isomorphic.js": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
+ "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
+ "peer": true,
+ "funding": {
+ "type": "GitHub Sponsors ❤",
+ "url": "https://github.com/sponsors/dmonad"
+ }
+ },
"node_modules/istanbul-lib-coverage": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz",
@@ -10095,6 +10344,31 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/lexical": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.10.0.tgz",
+ "integrity": "sha512-/lYJVpjQPOzFVfQWhnxRSP6HHBywiltzx9/pWRsByzXDZL+FTty3yjTsSFiCWy4PoVZaXqH8gYBe9dkxZK7+Hg=="
+ },
+ "node_modules/lib0": {
+ "version": "0.2.73",
+ "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.73.tgz",
+ "integrity": "sha512-aJJIElCLWnHMcYZPtsM07QoSfHwpxCy4VUzBYGXFYEmh/h2QS5uZNbCCfL0CqnkOE30b7Tp9DVfjXag+3qzZjQ==",
+ "peer": true,
+ "dependencies": {
+ "isomorphic.js": "^0.2.4"
+ },
+ "bin": {
+ "0gentesthtml": "bin/gentesthtml.js",
+ "0serve": "bin/0serve.js"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "type": "GitHub Sponsors ❤",
+ "url": "https://github.com/sponsors/dmonad"
+ }
+ },
"node_modules/lilconfig": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
@@ -10180,6 +10454,14 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lucide-react": {
+ "version": "0.171.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.171.0.tgz",
+ "integrity": "sha512-iCcpl4wfilDMeG1WtTkYTf47RMOx5KO5Kn5N8SSWq5KBxUELkHT7+p8+6wRz7GETxScA8fvjz22tFBmODnlYRg==",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -11351,6 +11633,14 @@
"node": ">= 0.8"
}
},
+ "node_modules/prismjs": {
+ "version": "1.29.0",
+ "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz",
+ "integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
@@ -11724,6 +12014,21 @@
"integrity": "sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==",
"dev": true
},
+ "node_modules/react-error-boundary": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz",
+ "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=10",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "react": ">=16.13.1"
+ }
+ },
"node_modules/react-inspector": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/react-inspector/-/react-inspector-6.0.1.tgz",
@@ -14451,6 +14756,23 @@
"fd-slicer": "~1.1.0"
}
},
+ "node_modules/yjs": {
+ "version": "13.5.53",
+ "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.5.53.tgz",
+ "integrity": "sha512-B4UUycEK8BcYf195HL4LN4Az4Sg2+QzTHnabFHjQwLvGn96v/G+4CS52xNZk/0QWNXhLRCb+2GK3JmcX5fiCEQ==",
+ "peer": true,
+ "dependencies": {
+ "lib0": "^0.2.72"
+ },
+ "engines": {
+ "node": ">=16.0.0",
+ "npm": ">=8.0.0"
+ },
+ "funding": {
+ "type": "GitHub Sponsors ❤",
+ "url": "https://github.com/sponsors/dmonad"
+ }
+ },
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
diff --git a/package.json b/package.json
@@ -4,6 +4,7 @@
"private": true,
"type": "module",
"dependencies": {
+ "@lexical/react": "^0.10.0",
"@reduxjs/toolkit": "^1.9.5",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
@@ -13,6 +14,8 @@
"@types/react": "^18.0.37",
"@types/react-dom": "^18.0.11",
"axios": "^1.3.5",
+ "lexical": "^0.10.0",
+ "lucide-react": "^0.171.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^8.0.5",
diff --git a/public/images/icons/edit.svg b/public/images/icons/edit.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-edit-3"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
+\ No newline at end of file
diff --git a/src/components/input/basic/input.stories.tsx b/src/components/input/basic/input.stories.tsx
@@ -0,0 +1,60 @@
+import { Meta, StoryObj } from '@storybook/react';
+import { Provider } from 'react-redux';
+import { store } from '../../../app/store';
+import Input from './input';
+import { BADGE } from '@geometricpanda/storybook-addon-badges';
+
+const meta: Meta<typeof Input> = {
+ title: 'Fundamentals/Inputs/BasicInput',
+ tags: ['autodocs'],
+ component: Input,
+ parameters: {
+ badges: [BADGE.EXPERIMENTAL]
+ },
+ argTypes: {
+ placeholder: {
+ required: true,
+ name: "Placeholder",
+ description: "The Placeholder for the input field. Shown if it is empty.",
+ },
+ password: {
+ required: false,
+ name: "Password",
+ control: "boolean",
+ defaultValue: false,
+ description: "If the input field takes a password.",
+ },
+ autoFocus: {
+ required: false,
+ name: "Auto Focus",
+ control: "boolean",
+ defaultValue: false,
+ description: "If the field should be focused automatically.",
+ },
+ value: {
+ required: true,
+ name: "Value",
+ description: "The current value of the input.",
+ },
+ onChange: {
+ description: "The onChange handler of the input field.",
+ required: true,
+ },
+ }
+};
+
+export default meta;
+type Story = StoryObj<typeof Input>;
+
+export const Default: Story = {
+ args: {
+ placeholder: "Placeholder",
+ password: false,
+ autoFocus: false,
+ value: "",
+ onChange: (_) => { }
+ },
+ render: (args) => <Provider store={store}>
+ <Input {...args} />
+ </Provider>,
+};
+\ No newline at end of file
diff --git a/src/components/input/input.tsx b/src/components/input/basic/input.tsx
diff --git a/src/components/input/chat/input.scss b/src/components/input/chat/input.scss
@@ -0,0 +1,477 @@
+.editor-container {
+ margin: 20px auto 20px auto;
+ border-radius: 2px;
+ max-width: 600px;
+ color: #000;
+ position: relative;
+ line-height: 20px;
+ font-weight: 400;
+ text-align: left;
+ border-top-left-radius: 10px;
+ border-top-right-radius: 10px;
+}
+
+.editor-inner {
+ background: #fff;
+ position: relative;
+}
+
+.editor-input {
+ min-height: 150px;
+ resize: none;
+ font-size: 15px;
+ caret-color: rgb(5, 5, 5);
+ position: relative;
+ tab-size: 1;
+ outline: 0;
+ padding: 15px 10px;
+ caret-color: #444;
+}
+
+.toolbar {
+ display: flex;
+ margin-bottom: 1px;
+ background: #fff;
+ padding: 4px;
+ border-top-left-radius: 10px;
+ border-top-right-radius: 10px;
+ vertical-align: middle;
+}
+
+.toolbar button.toolbar-item {
+ border: 0;
+ display: flex;
+ background: none;
+ border-radius: 10px;
+ padding: 8px;
+ cursor: pointer;
+ vertical-align: middle;
+}
+
+.toolbar button.toolbar-item:disabled {
+ cursor: not-allowed;
+}
+
+.toolbar button.toolbar-item.spaced {
+ margin-right: 2px;
+}
+
+.toolbar button.toolbar-item svg.format {
+ background-size: contain;
+ display: inline-block;
+ height: 18px;
+ width: 18px;
+ margin-top: 2px;
+ vertical-align: -0.25em;
+ display: flex;
+ opacity: 0.6;
+}
+
+.toolbar button.toolbar-item:disabled svg.format {
+ opacity: 0.2;
+}
+
+.toolbar button.toolbar-item.active {
+ background-color: rgba(223, 232, 250, 0.3);
+}
+
+.toolbar button.toolbar-item.active i {
+ opacity: 1;
+}
+
+.toolbar .toolbar-item:hover:not([disabled]) {
+ background-color: #eee;
+}
+
+.toolbar .divider {
+ width: 1px;
+ background-color: #eee;
+ margin: 0 4px;
+}
+
+.toolbar select.toolbar-item {
+ border: 0;
+ display: flex;
+ background: none;
+ border-radius: 10px;
+ padding: 8px;
+ vertical-align: middle;
+ width: 70px;
+ font-size: 14px;
+ color: #777;
+ text-overflow: ellipsis;
+}
+
+.toolbar select.code-language {
+ text-transform: capitalize;
+ width: 130px;
+}
+
+.toolbar .toolbar-item .text {
+ display: flex;
+ line-height: 20px;
+ width: 200px;
+ vertical-align: middle;
+ font-size: 14px;
+ color: #777;
+ text-overflow: ellipsis;
+ width: 70px;
+ overflow: hidden;
+ height: 20px;
+ text-align: left;
+}
+
+.toolbar .toolbar-item .icon {
+ display: flex;
+ width: 20px;
+ height: 20px;
+ user-select: none;
+ margin-right: 8px;
+ line-height: 16px;
+ background-size: contain;
+}
+
+.toolbar i.chevron-down {
+ margin-top: 3px;
+ width: 16px;
+ height: 16px;
+ display: flex;
+ user-select: none;
+}
+
+.toolbar i.chevron-down.inside {
+ width: 16px;
+ height: 16px;
+ display: flex;
+ margin-left: -25px;
+ margin-top: 11px;
+ margin-right: 10px;
+ pointer-events: none;
+}
+
+.link-editor {
+ position: absolute;
+ z-index: 100;
+ top: -10000px;
+ left: -10000px;
+ margin-top: -6px;
+ max-width: 300px;
+ width: 100%;
+ opacity: 0;
+ background-color: #fff;
+ box-shadow: 0px 5px 10px rgba(0, 0, 0, 0.3);
+ border-radius: 8px;
+ transition: opacity 0.5s;
+}
+
+.link-editor .link-input {
+ display: block;
+ width: calc(100% - 24px);
+ box-sizing: border-box;
+ margin: 8px 12px;
+ padding: 8px 12px;
+ border-radius: 15px;
+ background-color: #eee;
+ font-size: 15px;
+ color: rgb(5, 5, 5);
+ border: 0;
+ outline: 0;
+ position: relative;
+ font-family: inherit;
+}
+
+.link-editor div.link-edit {
+ background-image: url(images/icons/edit.svg);
+ background-size: 16px;
+ background-position: center;
+ background-repeat: no-repeat;
+ width: 35px;
+ vertical-align: -0.25em;
+ position: absolute;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ cursor: pointer;
+}
+
+.link-editor .link-input a {
+ color: rgb(33, 111, 219);
+ text-decoration: none;
+ display: block;
+ white-space: nowrap;
+ overflow: hidden;
+ margin-right: 30px;
+ text-overflow: ellipsis;
+}
+
+.link-editor .link-input a:hover {
+ text-decoration: underline;
+}
+
+.link-editor .button {
+ width: 20px;
+ height: 20px;
+ display: inline-block;
+ padding: 6px;
+ border-radius: 8px;
+ cursor: pointer;
+ margin: 0 2px;
+}
+
+.link-editor .button.hovered {
+ width: 20px;
+ height: 20px;
+ display: inline-block;
+ background-color: #eee;
+}
+
+.link-editor .button i,
+.actions i {
+ background-size: contain;
+ display: inline-block;
+ height: 20px;
+ width: 20px;
+ vertical-align: -0.25em;
+}
+
+.dropdown {
+ z-index: 5;
+ display: block;
+ position: absolute;
+ box-shadow: 0 12px 28px 0 rgba(0, 0, 0, 0.2), 0 2px 4px 0 rgba(0, 0, 0, 0.1),
+ inset 0 0 0 1px rgba(255, 255, 255, 0.5);
+ border-radius: 8px;
+ background-color: #fff;
+}
+
+.dropdown .item {
+ margin: 0 8px 0 8px;
+ padding: 8px;
+ color: #050505;
+ cursor: pointer;
+ line-height: 16px;
+ font-size: 15px;
+ display: flex;
+ align-content: center;
+ flex-direction: row;
+ flex-shrink: 0;
+ justify-content: space-between;
+ background-color: #fff;
+ border-radius: 8px;
+ border: 0;
+ min-width: 240px;
+}
+
+.dropdown .item .active {
+ display: flex;
+ width: 20px;
+ height: 20px;
+ background-size: contain;
+}
+
+.dropdown .item:first-child {
+ margin-top: 8px;
+}
+
+.dropdown .item:last-child {
+ margin-bottom: 8px;
+}
+
+.dropdown .item:hover {
+ background-color: #eee;
+}
+
+.dropdown .item .text {
+ display: flex;
+ line-height: 20px;
+ flex-grow: 1;
+}
+
+.dropdown .item .icon {
+ display: flex;
+ width: 20px;
+ height: 20px;
+ user-select: none;
+ margin-right: 12px;
+ line-height: 16px;
+ background-size: contain;
+}
+
+// Editor Theme starts here
+.ltr {
+ text-align: left;
+}
+
+.rtl {
+ text-align: right;
+}
+
+.editor-placeholder {
+ color: #999;
+ overflow: hidden;
+ position: absolute;
+ text-overflow: ellipsis;
+ top: 15px;
+ left: 10px;
+ font-size: 15px;
+ user-select: none;
+ display: inline-block;
+ pointer-events: none;
+}
+
+.editor-quote {
+ margin: 0;
+ margin-left: 20px;
+ font-size: 15px;
+ color: rgb(101, 103, 107);
+ border-left-color: rgb(206, 208, 212);
+ border-left-width: 4px;
+ border-left-style: solid;
+ padding-left: 16px;
+}
+
+.editor-heading-h1 {
+ font-size: 24px;
+ color: rgb(5, 5, 5);
+ font-weight: 400;
+ margin: 0;
+ margin-bottom: 12px;
+ padding: 0;
+}
+
+.editor-heading-h2 {
+ font-size: 15px;
+ color: rgb(101, 103, 107);
+ font-weight: 700;
+ margin: 0;
+ margin-top: 10px;
+ padding: 0;
+ text-transform: uppercase;
+}
+
+.editor-listitem {
+ margin: 8px 32px 8px 32px;
+}
+
+.editor-nested-listitem {
+ list-style-type: none;
+}
+
+.editor-list-ol {
+ @apply list-decimal;
+ padding: 0;
+ margin: 0;
+ margin-left: 16px;
+}
+
+.editor-list-ul {
+ @apply list-disc;
+ padding: 0;
+ margin: 0;
+ margin-left: 16px;
+}
+
+.editor-link {
+ color: rgb(33, 111, 219);
+ text-decoration: none;
+}
+
+.editor-text-bold {
+ font-weight: bold;
+}
+
+.editor-text-italic {
+ font-style: italic;
+}
+
+.editor-text-underline {
+ text-decoration: underline;
+}
+
+.editor-text-strikethrough {
+ text-decoration: line-through;
+}
+
+.editor-text-underlineStrikethrough {
+ text-decoration: underline line-through;
+}
+
+.editor-text-code {
+ background-color: rgb(240, 242, 245);
+ padding: 1px 0.25rem;
+ font-family: Menlo, Consolas, Monaco, monospace;
+ font-size: 94%;
+}
+
+.editor-code {
+ background-color: rgb(240, 242, 245);
+ font-family: Menlo, Consolas, Monaco, monospace;
+ display: block;
+ padding: 8px 8px 8px 52px;
+ line-height: 1.53;
+ font-size: 13px;
+ margin: 0;
+ margin-top: 8px;
+ margin-bottom: 8px;
+ tab-size: 2;
+ /* white-space: pre; */
+ overflow-x: auto;
+ position: relative;
+}
+
+.editor-code:before {
+ content: attr(data-gutter);
+ position: absolute;
+ background-color: #eee;
+ left: 0;
+ top: 0;
+ border-right: 1px solid #ccc;
+ padding: 8px;
+ color: #777;
+ white-space: pre-wrap;
+ text-align: right;
+ min-width: 25px;
+}
+
+.editor-code:after {
+ content: attr(data-highlight-language);
+ top: 0;
+ right: 3px;
+ padding: 3px;
+ font-size: 10px;
+ text-transform: uppercase;
+ position: absolute;
+ color: rgba(0, 0, 0, 0.5);
+}
+
+.editor-tokenComment {
+ color: slategray;
+}
+
+.editor-tokenPunctuation {
+ color: #999;
+}
+
+.editor-tokenProperty {
+ color: #905;
+}
+
+.editor-tokenSelector {
+ color: #690;
+}
+
+.editor-tokenOperator {
+ color: #9a6e3a;
+}
+
+.editor-tokenAttr {
+ color: #07a;
+}
+
+.editor-tokenVariable {
+ color: #e90;
+}
+
+.editor-tokenFunction {
+ color: #dd4a68;
+}
+\ No newline at end of file
diff --git a/src/components/input/chat/input.stories.tsx b/src/components/input/chat/input.stories.tsx
@@ -0,0 +1,53 @@
+import { Meta, StoryObj } from '@storybook/react';
+import { Provider } from 'react-redux';
+import { store } from '../../../app/store';
+import ChatInput from './input';
+import { BADGE } from '@geometricpanda/storybook-addon-badges';
+import { $getRoot, EditorState } from 'lexical';
+import { TRANSFORMERS, $convertToMarkdownString } from "@lexical/markdown";
+
+const meta: Meta<typeof ChatInput> = {
+ title: 'Chat/Input',
+ tags: ['autodocs'],
+ component: ChatInput,
+ parameters: {
+ badges: [BADGE.EXPERIMENTAL]
+ },
+ argTypes: {
+ namespace: {
+ required: true,
+ name: "Namespace",
+ description: "The Namespace of the editor.",
+ },
+ onChange: {
+ description: "The onChange handler of the editor.",
+ required: true,
+ },
+ onError: {
+ description: "The error handler of the editor.",
+ required: true,
+ },
+ }
+};
+
+export default meta;
+type Story = StoryObj<typeof ChatInput>;
+
+export const Default: Story = {
+ args: {
+ onChange: (editorState: EditorState) => {
+ editorState.read(() => {
+ // Read the contents of the EditorState here.
+ const root = $getRoot();
+ // make it markdown
+ const markdown = $convertToMarkdownString(TRANSFORMERS, root);
+
+ console.log(markdown);
+ });
+ },
+ onError: (error) => { console.error(error); }
+ },
+ render: (args) => <Provider store={store}>
+ <ChatInput {...args} />
+ </Provider>,
+};
+\ No newline at end of file
diff --git a/src/components/input/chat/input.tsx b/src/components/input/chat/input.tsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import { EditorState, LexicalEditor } from 'lexical';
+
+import { InitialConfigType, LexicalComposer } from '@lexical/react/LexicalComposer';
+import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
+import { ContentEditable } from '@lexical/react/LexicalContentEditable';
+import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
+import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
+import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';
+import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
+import { HeadingNode, QuoteNode } from "@lexical/rich-text";
+import { TableCellNode, TableNode, TableRowNode } from "@lexical/table";
+import { ListItemNode, ListNode } from "@lexical/list";
+import { CodeHighlightNode, CodeNode } from "@lexical/code";
+import { AutoLinkNode, LinkNode } from "@lexical/link";
+import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin";
+import { TRANSFORMERS } from "@lexical/markdown";
+
+import AutoLinkPlugin from "./plugins/AutoLinkPlugin";
+import ToolbarPlugin from "./plugins/ToolbarPlugin";
+import CodeHighlightPlugin from './plugins/CodeHighlightPlugin';
+import EditorTheme from './theme';
+
+import './input.scss';
+
+type ChatInputProps = {
+ /**
+ * The Namespace
+ */
+ namespace: string
+ /**
+ * Handler for the onChange event
+ */
+ onChange: (editorState: EditorState, editor: LexicalEditor, tags: Set<string>) => void;
+ /**
+ * Handler for the onError event
+ */
+ onError: (error: Error, editor: LexicalEditor) => void;
+};
+
+function Placeholder() {
+ return <div className="editor-placeholder">Enter some rich text...</div>;
+}
+
+export default function ChatInput({ namespace, onChange, onError }: ChatInputProps) {
+ const initialConfig: InitialConfigType = {
+ namespace: namespace,
+ theme: EditorTheme,
+ onError,
+ nodes: [
+ HeadingNode,
+ ListNode,
+ ListItemNode,
+ QuoteNode,
+ CodeNode,
+ CodeHighlightNode,
+ TableNode,
+ TableCellNode,
+ TableRowNode,
+ AutoLinkNode,
+ LinkNode
+ ]
+ }
+ return (
+ <LexicalComposer initialConfig={initialConfig}>
+ <div className="editor-container">
+ <ToolbarPlugin />
+ <div className="editor-inner">
+ <RichTextPlugin
+ contentEditable={<ContentEditable className="editor-input" />}
+ placeholder={<Placeholder />}
+ ErrorBoundary={LexicalErrorBoundary}
+ />
+ <OnChangePlugin onChange={onChange} />
+ <HistoryPlugin />
+ <LinkPlugin />
+ <CodeHighlightPlugin />
+ <AutoLinkPlugin />
+ <MarkdownShortcutPlugin transformers={TRANSFORMERS} />
+ </div>
+ </div>
+ </LexicalComposer>
+ );
+}
+\ No newline at end of file
diff --git a/src/components/input/chat/plugins/AutoLinkPlugin.tsx b/src/components/input/chat/plugins/AutoLinkPlugin.tsx
@@ -0,0 +1,34 @@
+import { AutoLinkPlugin, LinkMatcher } from "@lexical/react/LexicalAutoLinkPlugin";
+
+const URL_MATCHER = /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/;
+
+const EMAIL_MATCHER = /(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/;
+
+const MATCHERS: LinkMatcher[] = [
+ (text) => {
+ const match = URL_MATCHER.exec(text);
+ return (
+ match && {
+ index: match.index,
+ length: match[0].length,
+ text: match[0],
+ url: match[0]
+ }
+ );
+ },
+ (text) => {
+ const match = EMAIL_MATCHER.exec(text);
+ return (
+ match && {
+ index: match.index,
+ length: match[0].length,
+ text: match[0],
+ url: `mailto:${match[0]}`
+ }
+ );
+ }
+];
+
+export default function PlaygroundAutoLinkPlugin() {
+ return <AutoLinkPlugin matchers={MATCHERS} />;
+}
diff --git a/src/components/input/chat/plugins/CodeHighlightPlugin.ts b/src/components/input/chat/plugins/CodeHighlightPlugin.ts
@@ -0,0 +1,11 @@
+import { registerCodeHighlighting } from "@lexical/code";
+import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
+import { useEffect } from "react";
+
+export default function CodeHighlightPlugin() {
+ const [editor] = useLexicalComposerContext();
+ useEffect(() => {
+ return registerCodeHighlighting(editor);
+ }, [editor]);
+ return null;
+}
diff --git a/src/components/input/chat/plugins/ToolbarPlugin.tsx b/src/components/input/chat/plugins/ToolbarPlugin.tsx
@@ -0,0 +1,713 @@
+import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ ChangeEventHandler,
+ ChangeEvent,
+ MutableRefObject,
+ Dispatch,
+ SetStateAction
+} from "react";
+import {
+ CAN_REDO_COMMAND,
+ CAN_UNDO_COMMAND,
+ REDO_COMMAND,
+ UNDO_COMMAND,
+ SELECTION_CHANGE_COMMAND,
+ FORMAT_TEXT_COMMAND,
+ $getSelection,
+ $isRangeSelection,
+ $createParagraphNode,
+ $getNodeByKey,
+ LexicalEditor,
+ RangeSelection,
+ NodeSelection,
+ GridSelection
+} from "lexical";
+import { $isLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link";
+import {
+ $isParentElementRTL,
+ $wrapNodes,
+ $isAtNodeEnd
+} from "@lexical/selection";
+import { $getNearestNodeOfType, mergeRegister } from "@lexical/utils";
+import {
+ INSERT_ORDERED_LIST_COMMAND,
+ INSERT_UNORDERED_LIST_COMMAND,
+ REMOVE_LIST_COMMAND,
+ $isListNode,
+ ListNode
+} from "@lexical/list";
+import { createPortal } from "react-dom";
+import {
+ $createHeadingNode,
+ $createQuoteNode,
+ $isHeadingNode
+} from "@lexical/rich-text";
+import {
+ $createCodeNode,
+ $isCodeNode,
+ getDefaultCodeLanguage,
+ getCodeLanguages
+} from "@lexical/code";
+import { Bold, ChevronDown, Code, Heading1, Heading2, Heading4, Heading5, Italic, Link, List, ListOrdered, Quote, Redo, Strikethrough, Text, Underline, Undo } from "lucide-react";
+import { Heading3 } from "lucide-react";
+
+const LowPriority = 1;
+
+const supportedBlockTypes = new Set([
+ "paragraph",
+ "quote",
+ "code",
+ "h1",
+ "h2",
+ "ul",
+ "ol"
+]);
+
+interface BlockTypes {
+ code: string,
+ h1: string,
+ h2: string,
+ h3: string,
+ h4: string,
+ h5: string,
+ ol: string,
+ paragraph: string,
+ quote: string,
+ ul: string
+}
+
+const blockTypeToBlockName: BlockTypes = {
+ code: "Code Block",
+ h1: "Large Heading",
+ h2: "Small Heading",
+ h3: "Heading",
+ h4: "Heading",
+ h5: "Heading",
+ ol: "Numbered List",
+ paragraph: "Normal",
+ quote: "Quote",
+ ul: "Bulleted List"
+} as const;
+
+type BlockType = keyof typeof blockTypeToBlockName;
+
+function Divider() {
+ return <div className="divider" />;
+}
+
+function positionEditorElement(editor: HTMLDivElement, rect: DOMRect | undefined) {
+ if (!rect) {
+ editor.style.opacity = "0";
+ editor.style.top = "-1000px";
+ editor.style.left = "-1000px";
+ } else {
+ editor.style.opacity = "1";
+ editor.style.top = `${rect.top + rect.height + window.pageYOffset + 10}px`;
+ editor.style.left = `${rect.left + window.pageXOffset - editor.offsetWidth / 2 + rect.width / 2
+ }px`;
+ }
+}
+
+function FloatingLinkEditor({ editor }: { editor: LexicalEditor }) {
+ const editorRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
+ const inputRef: MutableRefObject<HTMLInputElement | null> = useRef(null);
+ const mouseDownRef = useRef(false);
+ const [linkUrl, setLinkUrl] = useState("");
+ const [isEditMode, setEditMode] = useState(false);
+ const [lastSelection, setLastSelection] = useState<RangeSelection | GridSelection | NodeSelection | null>(null);
+
+ const updateLinkEditor = useCallback(() => {
+ const selection = $getSelection();
+ if ($isRangeSelection(selection)) {
+ const node = getSelectedNode(selection);
+ const parent = node.getParent();
+ if ($isLinkNode(parent)) {
+ setLinkUrl(parent.getURL());
+ } else if ($isLinkNode(node)) {
+ setLinkUrl(node.getURL());
+ } else {
+ setLinkUrl("");
+ }
+ }
+ const editorElem = editorRef.current;
+ const nativeSelection = window.getSelection();
+ const activeElement = document.activeElement;
+
+ if (editorElem === null) {
+ return;
+ }
+
+ const rootElement = editor.getRootElement();
+ if (
+ selection !== null &&
+ !nativeSelection?.isCollapsed &&
+ rootElement !== null &&
+ rootElement.contains(nativeSelection?.anchorNode as (Node | null))
+ ) {
+ const domRange = nativeSelection?.getRangeAt(0);
+ let rect;
+ if (nativeSelection?.anchorNode === rootElement) {
+ let inner = rootElement;
+ while (inner.firstElementChild != null) {
+ inner = inner.firstElementChild as HTMLElement;
+ }
+ rect = inner.getBoundingClientRect();
+ } else {
+ rect = domRange?.getBoundingClientRect();
+ }
+
+ if (!mouseDownRef.current) {
+ positionEditorElement(editorElem, rect);
+ }
+ setLastSelection(selection);
+ } else if (!activeElement || activeElement.className !== "link-input") {
+ positionEditorElement(editorElem, undefined);
+ setLastSelection(null);
+ setEditMode(false);
+ setLinkUrl("");
+ }
+
+ return true;
+ }, [editor]);
+
+ useEffect(() => {
+ return mergeRegister(
+ editor.registerUpdateListener(({ editorState }) => {
+ editorState.read(() => {
+ updateLinkEditor();
+ });
+ }),
+
+ editor.registerCommand(
+ SELECTION_CHANGE_COMMAND,
+ () => {
+ updateLinkEditor();
+ return true;
+ },
+ LowPriority
+ )
+ );
+ }, [editor, updateLinkEditor]);
+
+ useEffect(() => {
+ editor.getEditorState().read(() => {
+ updateLinkEditor();
+ });
+ }, [editor, updateLinkEditor]);
+
+ useEffect(() => {
+ if (isEditMode && inputRef.current) {
+ inputRef.current.focus();
+ }
+ }, [isEditMode]);
+
+ return (
+ <div ref={editorRef} className="link-editor">
+ {isEditMode ? (
+ <input
+ ref={inputRef}
+ className="link-input"
+ value={linkUrl}
+ onChange={(event) => {
+ setLinkUrl(event.target.value);
+ }}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ if (lastSelection !== null) {
+ if (linkUrl !== "") {
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, linkUrl);
+ }
+ setEditMode(false);
+ }
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ setEditMode(false);
+ }
+ }}
+ />
+ ) : (
+ <>
+ <div className="link-input">
+ <a href={linkUrl} target="_blank" rel="noopener noreferrer">
+ {linkUrl}
+ </a>
+ <div
+ className="link-edit"
+ role="button"
+ tabIndex={0}
+ onMouseDown={(event) => event.preventDefault()}
+ onClick={() => {
+ setEditMode(true);
+ }}
+ />
+ </div>
+ </>
+ )}
+ </div>
+ );
+}
+
+function Select({ onChange, className, options, value }: { onChange: ChangeEventHandler<HTMLSelectElement>, className: string, options: string[], value: string | ReadonlyArray<string> | number }) {
+ return (
+ <select className={className} onChange={onChange} value={value}>
+ <option hidden={true} value="" />
+ {options.map((option) => (
+ <option key={option} value={option}>
+ {option}
+ </option>
+ ))}
+ </select>
+ );
+}
+
+function getSelectedNode(selection: RangeSelection | GridSelection) {
+ const anchor = selection.anchor;
+ const focus = selection.focus;
+ const anchorNode = selection.anchor.getNode();
+ const focusNode = selection.focus.getNode();
+ if (anchorNode === focusNode) {
+ return anchorNode;
+ }
+ const isBackward = selection.isBackward();
+ if (isBackward) {
+ return $isAtNodeEnd(focus) ? anchorNode : focusNode;
+ } else {
+ return $isAtNodeEnd(anchor) ? focusNode : anchorNode;
+ }
+}
+
+function BlockOptionsDropdownList({
+ editor,
+ blockType,
+ toolbarRef,
+ setShowBlockOptionsDropDown
+}: { editor: LexicalEditor, blockType: BlockType, toolbarRef: MutableRefObject<HTMLDivElement | null>, setShowBlockOptionsDropDown: Dispatch<SetStateAction<boolean>> }) {
+ const dropDownRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
+
+ useEffect(() => {
+ const toolbar = toolbarRef.current;
+ const dropDown = dropDownRef.current;
+
+ if (toolbar !== null && dropDown !== null) {
+ const { top, left } = toolbar.getBoundingClientRect();
+ dropDown.style.top = `${top + 40}px`;
+ dropDown.style.left = `${left}px`;
+ }
+ }, [dropDownRef, toolbarRef]);
+
+ useEffect(() => {
+ const dropDown = dropDownRef.current;
+ const toolbar = toolbarRef.current;
+
+ if (dropDown !== null && toolbar !== null) {
+ const handle = (event: MouseEvent) => {
+ const target = event.target;
+
+ if (!dropDown.contains(target as Node) && !toolbar.contains(target as Node)) {
+ setShowBlockOptionsDropDown(false);
+ }
+ };
+ document.addEventListener("click", handle);
+
+ return () => {
+ document.removeEventListener("click", handle);
+ };
+ }
+ }, [dropDownRef, setShowBlockOptionsDropDown, toolbarRef]);
+
+ const formatParagraph = () => {
+ if (blockType !== "paragraph") {
+ editor.update(() => {
+ const selection = $getSelection();
+
+ if ($isRangeSelection(selection)) {
+ $wrapNodes(selection, () => $createParagraphNode());
+ }
+ });
+ }
+ setShowBlockOptionsDropDown(false);
+ };
+
+ const formatLargeHeading = () => {
+ if (blockType !== "h1") {
+ editor.update(() => {
+ const selection = $getSelection();
+
+ if ($isRangeSelection(selection)) {
+ $wrapNodes(selection, () => $createHeadingNode("h1"));
+ }
+ });
+ }
+ setShowBlockOptionsDropDown(false);
+ };
+
+ const formatSmallHeading = () => {
+ if (blockType !== "h2") {
+ editor.update(() => {
+ const selection = $getSelection();
+
+ if ($isRangeSelection(selection)) {
+ $wrapNodes(selection, () => $createHeadingNode("h2"));
+ }
+ });
+ }
+ setShowBlockOptionsDropDown(false);
+ };
+
+ const formatBulletList = () => {
+ if (blockType !== "ul") {
+ editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
+ } else {
+ editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
+ }
+ setShowBlockOptionsDropDown(false);
+ };
+
+ const formatNumberedList = () => {
+ if (blockType !== "ol") {
+ editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
+ } else {
+ editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
+ }
+ setShowBlockOptionsDropDown(false);
+ };
+
+ const formatQuote = () => {
+ if (blockType !== "quote") {
+ editor.update(() => {
+ const selection = $getSelection();
+
+ if ($isRangeSelection(selection)) {
+ $wrapNodes(selection, () => $createQuoteNode());
+ }
+ });
+ }
+ setShowBlockOptionsDropDown(false);
+ };
+
+ const formatCode = () => {
+ if (blockType !== "code") {
+ editor.update(() => {
+ const selection = $getSelection();
+
+ if ($isRangeSelection(selection)) {
+ $wrapNodes(selection, () => $createCodeNode());
+ }
+ });
+ }
+ setShowBlockOptionsDropDown(false);
+ };
+
+ return (
+ <div className="dropdown" ref={dropDownRef}>
+ <button className="item" onClick={formatParagraph}>
+ <Text className="icon" size={20} />
+ <span className="text">Normal</span>
+ {blockType === "paragraph" && <span className="active" />}
+ </button>
+ <button className="item" onClick={formatLargeHeading}>
+ <Heading1 className="icon" size={20} />
+ <span className="text">Large Heading</span>
+ {blockType === "h1" && <span className="active" />}
+ </button>
+ <button className="item" onClick={formatSmallHeading}>
+ <Heading2 className="icon" size={20} />
+ <span className="text">Small Heading</span>
+ {blockType === "h2" && <span className="active" />}
+ </button>
+ <button className="item" onClick={formatBulletList}>
+ <List className="icon" size={20} />
+ <span className="text">Bullet List</span>
+ {blockType === "ul" && <span className="active" />}
+ </button>
+ <button className="item" onClick={formatNumberedList}>
+ <ListOrdered className="icon" size={20} />
+ <span className="text">Numbered List</span>
+ {blockType === "ol" && <span className="active" />}
+ </button>
+ <button className="item" onClick={formatQuote}>
+ <Quote className="icon" size={20} />
+ <span className="text">Quote</span>
+ {blockType === "quote" && <span className="active" />}
+ </button>
+ <button className="item" onClick={formatCode}>
+ <Code className="icon" size={20} />
+ <span className="text">Code Block</span>
+ {blockType === "code" && <span className="active" />}
+ </button>
+ </div>
+ );
+}
+
+export default function ToolbarPlugin() {
+ const [editor] = useLexicalComposerContext();
+ const toolbarRef = useRef(null);
+ const [canUndo, setCanUndo] = useState(false);
+ const [canRedo, setCanRedo] = useState(false);
+ const [blockType, setBlockType] = useState<BlockType>("paragraph");
+ const [selectedElementKey, setSelectedElementKey] = useState(null);
+ const [showBlockOptionsDropDown, setShowBlockOptionsDropDown] = useState(
+ false
+ );
+ const [codeLanguage, setCodeLanguage] = useState("");
+ const [isRTL, setIsRTL] = useState(false);
+ const [isLink, setIsLink] = useState(false);
+ const [isBold, setIsBold] = useState(false);
+ const [isItalic, setIsItalic] = useState(false);
+ const [isUnderline, setIsUnderline] = useState(false);
+ const [isStrikethrough, setIsStrikethrough] = useState(false);
+ const [isCode, setIsCode] = useState(false);
+
+ const updateToolbar = useCallback(() => {
+ const selection = $getSelection();
+ if ($isRangeSelection(selection)) {
+ const anchorNode = selection.anchor.getNode();
+ const element =
+ anchorNode.getKey() === "root"
+ ? anchorNode
+ : anchorNode.getTopLevelElementOrThrow();
+ const elementKey = element.getKey();
+ const elementDOM = editor.getElementByKey(elementKey);
+ if (elementDOM !== null) {
+ setSelectedElementKey(elementKey);
+ if ($isListNode(element)) {
+ const parentList = $getNearestNodeOfType(anchorNode, ListNode);
+ const type = parentList ? parentList.getTag() : element.getTag();
+ setBlockType(type);
+ } else {
+ const type = $isHeadingNode(element)
+ ? element.getTag()
+ : element.getType();
+ setBlockType(type);
+ if ($isCodeNode(element)) {
+ setCodeLanguage(element.getLanguage() || getDefaultCodeLanguage());
+ }
+ }
+ }
+ // Update text format
+ setIsBold(selection.hasFormat("bold"));
+ setIsItalic(selection.hasFormat("italic"));
+ setIsUnderline(selection.hasFormat("underline"));
+ setIsStrikethrough(selection.hasFormat("strikethrough"));
+ setIsCode(selection.hasFormat("code"));
+ setIsRTL($isParentElementRTL(selection));
+
+ // Update links
+ const node = getSelectedNode(selection);
+ const parent = node.getParent();
+ if ($isLinkNode(parent) || $isLinkNode(node)) {
+ setIsLink(true);
+ } else {
+ setIsLink(false);
+ }
+ }
+ }, [editor]);
+
+ useEffect(() => {
+ return mergeRegister(
+ editor.registerUpdateListener(({ editorState }) => {
+ editorState.read(() => {
+ updateToolbar();
+ });
+ }),
+ editor.registerCommand(
+ SELECTION_CHANGE_COMMAND,
+ (_payload, _newEditor) => {
+ updateToolbar();
+ return false;
+ },
+ LowPriority
+ ),
+ editor.registerCommand(
+ CAN_UNDO_COMMAND,
+ (payload) => {
+ setCanUndo(payload);
+ return false;
+ },
+ LowPriority
+ ),
+ editor.registerCommand(
+ CAN_REDO_COMMAND,
+ (payload) => {
+ setCanRedo(payload);
+ return false;
+ },
+ LowPriority
+ )
+ );
+ }, [editor, updateToolbar]);
+
+ const codeLanguges = useMemo(() => getCodeLanguages(), []);
+ const onCodeLanguageSelect = useCallback(
+ (e: ChangeEvent<HTMLSelectElement>) => {
+ editor.update(() => {
+ if (selectedElementKey !== null) {
+ const node = $getNodeByKey(selectedElementKey);
+ if ($isCodeNode(node)) {
+ node.setLanguage(e.target.value);
+ }
+ }
+ });
+ },
+ [editor, selectedElementKey]
+ );
+
+ const insertLink = useCallback(() => {
+ if (!isLink) {
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, "https://");
+ } else {
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
+ }
+ }, [editor, isLink]);
+
+ return (
+ <div className="toolbar" ref={toolbarRef}>
+ <button
+ disabled={!canUndo}
+ onClick={() => {
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ }}
+ className="toolbar-item spaced"
+ aria-label="Undo"
+ >
+ <Undo className="format" size={20} />
+ </button>
+ <button
+ disabled={!canRedo}
+ onClick={() => {
+ editor.dispatchCommand(REDO_COMMAND, undefined);
+ }}
+ className="toolbar-item"
+ aria-label="Redo"
+ >
+ <Redo className="format" size={20} />
+ </button>
+ <Divider />
+ {supportedBlockTypes.has(blockType) && (
+ <>
+ <button
+ className="toolbar-item block-controls"
+ onClick={() =>
+ setShowBlockOptionsDropDown(!showBlockOptionsDropDown)
+ }
+ aria-label="Formatting Options"
+ >
+ {
+ blockType === "h1" ?
+ <Heading1 className="icon" size={20} /> :
+ blockType === "h2" ?
+ <Heading2 className="icon" size={20} /> :
+ blockType === "h3" ?
+ <Heading3 className="icon" size={20} /> :
+ blockType === "h4" ?
+ <Heading4 className="icon" size={20} /> :
+ blockType === "h5" ?
+ <Heading5 className="icon" size={20} /> :
+ blockType === "code" ?
+ <Code className="icon" size={20} /> :
+ blockType === "paragraph" ?
+ <Text className="icon" size={20} /> :
+ blockType === "ol" ?
+ <ListOrdered className="icon" size={20} /> :
+ blockType === "ul" ?
+ <List className="icon" size={20} /> :
+ blockType === "quote" ?
+ <Quote className="icon" size={20} /> :
+ <></>
+ }
+ <span className="text">{blockTypeToBlockName[blockType]}</span>
+ <ChevronDown size={20} />
+ </button>
+ {showBlockOptionsDropDown &&
+ createPortal(
+ <BlockOptionsDropdownList
+ editor={editor}
+ blockType={blockType}
+ toolbarRef={toolbarRef}
+ setShowBlockOptionsDropDown={setShowBlockOptionsDropDown}
+ />,
+ document.body
+ )}
+ <Divider />
+ </>
+ )}
+ {blockType === "code" ? (
+ <>
+ <Select
+ className="toolbar-item code-language"
+ onChange={onCodeLanguageSelect}
+ options={codeLanguges}
+ value={codeLanguage}
+ />
+ <i className="chevron-down inside" />
+ </>
+ ) : (
+ <>
+ <button
+ onClick={() => {
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
+ }}
+ className={"toolbar-item spaced " + (isBold ? "active" : "")}
+ aria-label="Format Bold"
+ >
+ <Bold className="format" size={20} />
+ </button>
+ <button
+ onClick={() => {
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
+ }}
+ className={"toolbar-item spaced " + (isItalic ? "active" : "")}
+ aria-label="Format Italics"
+ >
+ <Italic className="format" size={20} />
+ </button>
+ <button
+ onClick={() => {
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline");
+ }}
+ className={"toolbar-item spaced " + (isUnderline ? "active" : "")}
+ aria-label="Format Underline"
+ >
+ <Underline className="format" size={20} />
+ </button>
+ <button
+ onClick={() => {
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough");
+ }}
+ className={
+ "toolbar-item spaced " + (isStrikethrough ? "active" : "")
+ }
+ aria-label="Format Strikethrough"
+ >
+ <Strikethrough className="format" size={20} />
+ </button>
+ <button
+ onClick={() => {
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code");
+ }}
+ className={"toolbar-item spaced " + (isCode ? "active" : "")}
+ aria-label="Insert Code"
+ >
+ <Code className="format" size={20} />
+ </button>
+ <button
+ onClick={insertLink}
+ className={"toolbar-item spaced " + (isLink ? "active" : "")}
+ aria-label="Insert Link"
+ >
+ <Link className="format" size={20} />
+ </button>
+ {isLink &&
+ createPortal(<FloatingLinkEditor editor={editor} />, document.body)}
+ {" "}
+ </>
+ )}
+ </div>
+ );
+}
diff --git a/src/components/input/chat/theme.ts b/src/components/input/chat/theme.ts
@@ -0,0 +1,69 @@
+const editorTheme = {
+ ltr: "ltr",
+ rtl: "rtl",
+ placeholder: "editor-placeholder",
+ paragraph: "editor-paragraph",
+ quote: "editor-quote",
+ heading: {
+ h1: "editor-heading-h1",
+ h2: "editor-heading-h2",
+ h3: "editor-heading-h3",
+ h4: "editor-heading-h4",
+ h5: "editor-heading-h5"
+ },
+ list: {
+ nested: {
+ listitem: "editor-nested-listitem"
+ },
+ ol: "editor-list-ol",
+ ul: "editor-list-ul",
+ listitem: "editor-listitem"
+ },
+ image: "editor-image",
+ link: "editor-link",
+ text: {
+ bold: "editor-text-bold",
+ italic: "editor-text-italic",
+ overflowed: "editor-text-overflowed",
+ hashtag: "editor-text-hashtag",
+ underline: "editor-text-underline",
+ strikethrough: "editor-text-strikethrough",
+ underlineStrikethrough: "editor-text-underlineStrikethrough",
+ code: "editor-text-code"
+ },
+ code: "editor-code",
+ codeHighlight: {
+ atrule: "editor-tokenAttr",
+ attr: "editor-tokenAttr",
+ boolean: "editor-tokenProperty",
+ builtin: "editor-tokenSelector",
+ cdata: "editor-tokenComment",
+ char: "editor-tokenSelector",
+ class: "editor-tokenFunction",
+ "class-name": "editor-tokenFunction",
+ comment: "editor-tokenComment",
+ constant: "editor-tokenProperty",
+ deleted: "editor-tokenProperty",
+ doctype: "editor-tokenComment",
+ entity: "editor-tokenOperator",
+ function: "editor-tokenFunction",
+ important: "editor-tokenVariable",
+ inserted: "editor-tokenSelector",
+ keyword: "editor-tokenAttr",
+ namespace: "editor-tokenVariable",
+ number: "editor-tokenProperty",
+ operator: "editor-tokenOperator",
+ prolog: "editor-tokenComment",
+ property: "editor-tokenProperty",
+ punctuation: "editor-tokenPunctuation",
+ regex: "editor-tokenVariable",
+ selector: "editor-tokenSelector",
+ string: "editor-tokenSelector",
+ symbol: "editor-tokenProperty",
+ tag: "editor-tokenProperty",
+ url: "editor-tokenOperator",
+ variable: "editor-tokenVariable"
+ }
+};
+
+export default editorTheme;
diff --git a/src/components/input/input.stories.tsx b/src/components/input/input.stories.tsx
@@ -1,60 +0,0 @@
-import { Meta, StoryObj } from '@storybook/react';
-import { Provider } from 'react-redux';
-import { store } from '../../app/store';
-import Input from './input';
-import { BADGE } from '@geometricpanda/storybook-addon-badges';
-
-const meta: Meta<typeof Input> = {
- title: 'Fundamentals/Inputs/BasicInput',
- tags: ['autodocs'],
- component: Input,
- parameters: {
- badges: [BADGE.EXPERIMENTAL]
- },
- argTypes: {
- placeholder: {
- required: true,
- name: "Placeholder",
- description: "The Placeholder for the input field. Shown if it is empty.",
- },
- password: {
- required: false,
- name: "Password",
- control: "boolean",
- defaultValue: false,
- description: "If the input field takes a password.",
- },
- autoFocus: {
- required: false,
- name: "Auto Focus",
- control: "boolean",
- defaultValue: false,
- description: "If the field should be focused automatically.",
- },
- value: {
- required: true,
- name: "Value",
- description: "The current value of the input.",
- },
- onChange: {
- description: "The onChange handler of the input field.",
- required: true,
- },
- }
-};
-
-export default meta;
-type Story = StoryObj<typeof Input>;
-
-export const Default: Story = {
- args: {
- placeholder: "Placeholder",
- password: false,
- autoFocus: false,
- value: "",
- onChange: (_) => { }
- },
- render: (args) => <Provider store={store}>
- <Input {...args} />
- </Provider>,
-};
-\ No newline at end of file
diff --git a/src/components/login/login.tsx b/src/components/login/login.tsx
@@ -5,7 +5,7 @@ import { useAppDispatch, useAppSelector } from '../../app/hooks';
import { RootState } from '../../app/store';
import Button from '../button/button';
import Header from '../header/header';
-import Input from '../input/input';
+import Input from '../input/basic/input';
export function Login() {
const loginError = useAppSelector((state: RootState) => getLoginError(state));