diff --git a/.github/workflows/cypress-testing.yml b/.github/workflows/cypress-testing.yml index aaf344530..4004b6b2b 100644 --- a/.github/workflows/cypress-testing.yml +++ b/.github/workflows/cypress-testing.yml @@ -4,7 +4,7 @@ on: push: branches: [master] pull_request: - branches: [master] + branches: [master, migration-from-draft-js] jobs: glific: @@ -95,6 +95,9 @@ jobs: echo clone cypress repo git clone https://github.com/glific/cypress-testing.git echo done. go to dir. + cd cypress-testing + git checkout lexical-editor + cd .. cp -r cypress-testing/cypress cypress yarn add cypress echo Create cypress.config.ts from example diff --git a/package.json b/package.json index f839115ac..5bfe32537 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,12 @@ "@appsignal/plugin-window-events": "^1.0.20", "@appsignal/react": "^1.0.23", "@date-io/dayjs": "^3.0.0", - "@draft-js-plugins/editor": "^4.1.4", - "@draft-js-plugins/mention": "^5.2.2", "@emoji-mart/data": "^1.1.2", "@emoji-mart/react": "^1.1.1", "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", "@glific/flow-editor": "^1.19.3-5", + "@lexical/react": "^0.13.1", "@mui/icons-material": "^5.15.6", "@mui/material": "^5.15.6", "@mui/x-date-pickers": "^6.19.2", @@ -32,7 +31,6 @@ "axios": "^1.6.7", "buffer": "^6.0.3", "dayjs": "^1.11.10", - "draft-js": "^0.11.7", "emoji-mart": "^5.5.2", "formik": "^2.4.5", "graphql": "^16.8.1", @@ -41,6 +39,8 @@ "i18next-browser-languagedetector": "^7.2.0", "interweave": "^13.1.0", "interweave-autolink": "^5.1.1", + "lexical": "^0.13.1", + "lexical-beautiful-mentions": "^0.1.30", "pino": "^8.17.2", "pino-logflare": "^0.4.2", "react": "^18.2.0", diff --git a/src/App.test.tsx b/src/App.test.tsx index b13876d04..476c38a63 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,3 +1,4 @@ +import 'mocks/matchMediaMock'; import { MemoryRouter } from 'react-router-dom'; import { MockedProvider } from '@apollo/client/testing'; import { waitFor, render } from '@testing-library/react'; diff --git a/src/common/LexicalWrapper.tsx b/src/common/LexicalWrapper.tsx new file mode 100644 index 000000000..69ef86a4f --- /dev/null +++ b/src/common/LexicalWrapper.tsx @@ -0,0 +1,20 @@ +import { LexicalComposer } from '@lexical/react/LexicalComposer'; +import { BeautifulMentionNode } from 'lexical-beautiful-mentions'; + +interface LexicalWrapperProps { + children: any; +} + +export const LexicalWrapper = ({ children }: LexicalWrapperProps) => { + return ( + console.log(error), + nodes: [BeautifulMentionNode], + }} + > + {children} + + ); +}; diff --git a/src/common/RichEditor.tsx b/src/common/RichEditor.tsx index b796d820d..c439cf26e 100644 --- a/src/common/RichEditor.tsx +++ b/src/common/RichEditor.tsx @@ -1,4 +1,3 @@ -import { EditorState, ContentState } from 'draft-js'; import CallIcon from '@mui/icons-material/Call'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import { Interweave } from 'interweave'; @@ -8,12 +7,30 @@ import { UrlMatcher } from 'interweave-autolink'; const regexForLink = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_+.~#?&/=]*)/gi; -// Convert Draft.js to WhatsApp message format. -export const getPlainTextFromEditor = (editorState: any) => - editorState.getCurrentContent().getPlainText(); +export const handleFormatterEvents = (event: KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && event.code === 'KeyB') { + return 'bold'; + } else if ((event.ctrlKey || event.metaKey) && event.code === 'KeyI') { + return 'italic'; + } else if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') { + return 'strikethrough'; + } + + return ''; +}; -export const getEditorFromContent = (text: string) => - EditorState.createWithContent(ContentState.createFromText(text)); +export const handleFormatting = (text: string, formatter: string) => { + switch (formatter) { + case 'bold': + return `*${text}*`; + case 'italic': + return `_${text}_`; + case 'strikethrough': + return `~${text}~`; + default: + return text; + } +}; const isAlphanumeric = (c: any) => { const x = c.charCodeAt(); diff --git a/src/components/UI/EmojiPicker/EmojiPicker.tsx b/src/components/UI/EmojiPicker/EmojiPicker.tsx index 6e11e9521..2c8481300 100644 --- a/src/components/UI/EmojiPicker/EmojiPicker.tsx +++ b/src/components/UI/EmojiPicker/EmojiPicker.tsx @@ -6,10 +6,12 @@ export interface EmojiPickerProps { onEmojiSelect: Function; } -export const EmojiPicker = ({ displayStyle, onEmojiSelect }: EmojiPickerProps) => ( -
- -
-); +export const EmojiPicker = ({ displayStyle, onEmojiSelect }: EmojiPickerProps) => { + return ( +
+ +
+ ); +}; export default EmojiPicker; diff --git a/src/components/UI/Form/EmojiInput/Editor.module.css b/src/components/UI/Form/EmojiInput/Editor.module.css new file mode 100644 index 000000000..646173caa --- /dev/null +++ b/src/components/UI/Form/EmojiInput/Editor.module.css @@ -0,0 +1,160 @@ +.HelperText { + margin-left: 16px !important; + color: #93a29b !important; + line-height: 1 !important; + font-size: 12px !important; + margin-top: 4px !important; +} + +.Editor { + position: relative; + border: 2px solid rgba(0, 0, 0, 0.23); + border-radius: 12px; + height: 10rem; + position: relative; + padding: 1rem; + position: relative; + display: block; + border-bottom-left-radius: 10px; + border-bottom-right-radius: 10px; +} + +.Label { + font-size: 16px; + font-weight: 500; + color: #93a29b !important; +} + +.DangerText { + margin-left: 16px !important; + color: #fb5c5c; +} + +.EditorInput { + resize: none; + font-size: 16px; + width: 100%; + min-height: 40px; + height: 100%; + margin-top: 4px; + position: relative; + tab-size: 1; + outline: 0; +} + +.EditorInput p { + overflow: scroll !important; + margin: 0 !important; +} + +.EditorWrapper { + margin: 1rem 0; +} + +.editorScroller { + border: 0; + display: flex; + position: relative; + outline: 0; + z-index: 0; + height: 100%; + overflow: auto; +} + +.disabled { + position: relative; + border: 2px solid rgba(0, 0, 0, 0.23); + border-radius: 12px; + height: 10rem; + position: relative; + padding: 1rem; + position: relative; + display: block; + border-bottom-left-radius: 10px; + border-bottom-right-radius: 10px; + color: rgba(0, 0, 0, 0.38); +} + +.editor { + flex: auto; + position: relative; + resize: vertical; + z-index: -1; +} + +.editorPlaceholder { + color: #999; + overflow: hidden; + position: absolute; + text-overflow: ellipsis; + top: 1px; + left: 14px; + font-size: 16px; + user-select: none; + display: inline-block; + pointer-events: none; +} + +/* + Overriding lexical mentions styles as per requirement +*/ +.MentionMenu { + padding: 0; + background: #fff; + box-shadow: 0px 5px 10px rgba(0, 0, 0, 0.3); + border-radius: 8px; + list-style: none; + margin: 0; + max-height: 200px; + overflow-y: scroll; + -ms-overflow-style: none; + width: 250px; + scrollbar-width: none; +} + +.MentionMenu ul li { + margin: 0; + min-width: 180px; + font-size: 14px; + outline: none; + cursor: pointer; + border-radius: 8px; +} + +.Selected { + background: #eee !important; +} + +.MentionMenu li { + padding: 8px; + color: #050505; + cursor: pointer; + line-height: 16px; + font-size: 15px; + display: flex; + align-content: center; + flex-direction: row; + flex-shrink: 0; + background-color: #fff; + border-radius: 8px; + border: 0; +} + +.MentionMenu li.active { + display: flex; + width: 20px; + height: 20px; + background-size: contain; +} + +.MentionMenu li:first-child { + border-radius: 8px 8px 0px 0px; +} + +.MentionMenu li:last-child { + border-radius: 0px 0px 8px 8px; +} + +.MentionMenu li:hover { + background-color: #eee; +} diff --git a/src/components/UI/Form/EmojiInput/Editor.test.tsx b/src/components/UI/Form/EmojiInput/Editor.test.tsx new file mode 100644 index 000000000..3f2f58504 --- /dev/null +++ b/src/components/UI/Form/EmojiInput/Editor.test.tsx @@ -0,0 +1,31 @@ +import 'mocks/matchMediaMock'; +import { render } from '@testing-library/react'; +import { Editor } from './Editor'; +import { LexicalWrapper } from 'common/LexicalWrapper'; + +const mockIntersectionObserver = class { + constructor() {} + observe() {} + unobserve() {} + disconnect() {} +}; + +const lexicalChange = vi.fn; + +(window as any).IntersectionObserver = mockIntersectionObserver; + +const wrapper = ( + + {} }} + placeholder={''} + onChange={lexicalChange} + /> + +); + +it('should render lexical editor', () => { + const { getByTestId } = render(wrapper); + expect(getByTestId('editor-body')).toBeInTheDocument(); +}); diff --git a/src/components/UI/Form/EmojiInput/Editor.tsx b/src/components/UI/Form/EmojiInput/Editor.tsx new file mode 100644 index 000000000..e7c46ef47 --- /dev/null +++ b/src/components/UI/Form/EmojiInput/Editor.tsx @@ -0,0 +1,151 @@ +import styles from './Editor.module.css'; +import { forwardRef, useState, useEffect } from 'react'; +import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin'; +import { ContentEditable } from '@lexical/react/LexicalContentEditable'; +import { + $getSelection, + $createTextNode, + $getRoot, + $createParagraphNode, + KEY_DOWN_COMMAND, + COMMAND_PRIORITY_LOW, +} from 'lexical'; +import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary'; +import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; +import { FormHelperText } from '@mui/material'; +import { + BeautifulMentionsPlugin, + BeautifulMentionsMenuProps, + BeautifulMentionsMenuItemProps, +} from 'lexical-beautiful-mentions'; +import { handleFormatterEvents, handleFormatting } from 'common/RichEditor'; + +export interface EditorProps { + field: { name: string; onChange?: any; value: any; onBlur: any }; + disabled?: any; + form?: { touched: any; errors: any }; + placeholder: string; + helperText?: string; + picker?: any; + inputProp?: any; + onChange?: any; + isEditing: boolean; +} + +export const Editor = ({ disabled = false, isEditing = false, ...props }: EditorProps) => { + const [editorState, setEditorState] = useState(''); + const { field, form, picker, placeholder, onChange } = props; + const mentions = props.inputProp?.suggestions || []; + const suggestions = { + '@': mentions.map((mention: string) => mention?.split('@')[1]), + }; + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (field.value && isEditing && !editorState) { + editor.update(() => { + const root = $getRoot(); + root.clear(); + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode(field.value || '')); + root.append(paragraph); + }); + } + }, [field.value]); + + const Placeholder = () => { + return

{placeholder}

; + }; + + useEffect(() => { + return editor.registerCommand( + KEY_DOWN_COMMAND, + (event: KeyboardEvent) => { + let formatter = handleFormatterEvents(event); + + editor.update(() => { + const selection = $getSelection(); + if (selection?.getTextContent() && formatter) { + const text = handleFormatting(selection?.getTextContent(), formatter); + const newNode = $createTextNode(text); + selection?.insertNodes([newNode]); + } + }); + return false; + }, + COMMAND_PRIORITY_LOW + ); + }, [editor]); + + useEffect(() => { + if (disabled) { + editor.setEditable(false); + } + }, [disabled]); + + const handleChange = (editorState: any) => { + editorState.read(() => { + const root = $getRoot(); + if (!disabled) { + onChange(root.getTextContent()); + setEditorState(root.getTextContent()); + } + }); + }; + + return ( +
+
+ } + contentEditable={ +
+
+ +
+
+ } + ErrorBoundary={LexicalErrorBoundary} + /> + + + + {picker} +
+ {form && form.errors[field.name] && form.touched[field.name] ? ( + {form.errors[field.name]} + ) : null} + {props.helperText && ( + {props.helperText} + )} +
+ ); +}; + +const CustomMenu = forwardRef( + ({ open, loading, ...props }, ref) =>