cetirizine

An experimental matrix client written in reactjs utilizing tailwind and storybook
git clone git://archive.git.mtrnord.blog/MTRNord/cetirizine.git
Log | Files | Refs | README | LICENSE

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:
Mpackage-lock.json | 322+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackage.json | 3+++
Apublic/images/icons/edit.svg | 2++
Asrc/components/input/basic/input.stories.tsx | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rsrc/components/input/input.tsx -> src/components/input/basic/input.tsx | 0
Asrc/components/input/chat/input.scss | 478+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/input/chat/input.stories.tsx | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/input/chat/input.tsx | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/input/chat/plugins/AutoLinkPlugin.tsx | 34++++++++++++++++++++++++++++++++++
Asrc/components/input/chat/plugins/CodeHighlightPlugin.ts | 11+++++++++++
Asrc/components/input/chat/plugins/ToolbarPlugin.tsx | 713+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/input/chat/theme.ts | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/components/input/input.stories.tsx | 61-------------------------------------------------------------
Msrc/components/login/login.tsx | 2+-
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));