Skip to content

Commit

Permalink
Merge pull request #3210 from glific/enhancement/rich-text-editor
Browse files Browse the repository at this point in the history
Added support for rich text editor
  • Loading branch information
kurund authored Feb 18, 2025
2 parents 207fe06 + b3ab67c commit 6d381d2
Show file tree
Hide file tree
Showing 7 changed files with 205 additions and 54 deletions.
9 changes: 5 additions & 4 deletions src/common/RichEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@ export const handleFormatterEvents = (event: KeyboardEvent) => {
return '';
};

export const handleFormatting = (text: string, formatter: string) => {
export const handleFormatting = (text: string = '', formatter: string) => {
switch (formatter) {
case 'bold':
return `*${text}*`;
return text.startsWith('*') && text.endsWith('*') ? text.slice(1, -1) : `*${text}*`;
case 'italic':
return `_${text}_`;
return text.startsWith('_') && text.endsWith('_') ? text.slice(1, -1) : `_${text}_`;
case 'strikethrough':
return `~${text}~`;
return text.startsWith('~') && text.endsWith('~') ? text.slice(1, -1) : `~${text}~`;

default:
return text;
}
Expand Down
39 changes: 36 additions & 3 deletions src/components/UI/Form/EmojiInput/Editor.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@

.Editor {
position: relative;
border: 2px solid rgba(0, 0, 0, 0.23);
border-radius: 12px;

height: 10rem;
position: relative;
padding: 1rem;
Expand Down Expand Up @@ -47,7 +46,8 @@
}

.EditorWrapper {
margin: 1rem 0;
border: 2px solid rgba(0, 0, 0, 0.23);
border-radius: 12px;
}

.editorScroller {
Expand Down Expand Up @@ -157,3 +157,36 @@
.MentionMenu li:hover {
background-color: #eee;
}


.FormatingOptions {
border-bottom: 1px solid #ccc;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
display: flex;
padding: 0.2rem;
justify-content: flex-end;
}

.FormatingOptions span {
border: 0;
display: flex;
background: none;
border-radius: 10px;
padding: 8px;
cursor: pointer;
vertical-align: middle;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
color: #5F5F5F;

}

.Active {
background-color: #eee !important;
}

.FormatingOptions span:hover {
background-color: #eee;
}
193 changes: 152 additions & 41 deletions src/components/UI/Form/EmojiInput/Editor.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import styles from './Editor.module.css';
import { forwardRef, useEffect } from 'react';
import { forwardRef, useEffect, useState } from 'react';
import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { $getSelection, $createTextNode, $getRoot, KEY_DOWN_COMMAND, COMMAND_PRIORITY_LOW } from 'lexical';
import {
$getSelection,
$createTextNode,
$getRoot,
COMMAND_PRIORITY_LOW,
FORMAT_TEXT_COMMAND,
$createRangeSelection,
$setSelection,
$isRangeSelection,
} from 'lexical';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';
Expand All @@ -13,7 +22,8 @@ import {
BeautifulMentionsMenuProps,
BeautifulMentionsMenuItemProps,
} from 'lexical-beautiful-mentions';
import { handleFormatterEvents, handleFormatting, setDefaultValue } from 'common/RichEditor';
import { handleFormatting, setDefaultValue } from 'common/RichEditor';
import { FormatBold, FormatItalic, StrikethroughS } from '@mui/icons-material';

export interface EditorProps {
field: { name: string; onChange?: any; value: any; onBlur?: any };
Expand All @@ -34,29 +44,39 @@ export const Editor = ({ disabled = false, ...props }: EditorProps) => {
'@': mentions.map((mention: string) => mention?.split('@')[1]),
};
const [editor] = useLexicalComposerContext();
const [activeFormats, setActiveFormats] = useState<{ bold: boolean; italic: boolean; strikethrough: boolean }>({
bold: false,
italic: false,
strikethrough: false,
});

useEffect(() => {
if (defaultValue || defaultValue === '') {
setDefaultValue(editor, defaultValue);
}
}, [defaultValue]);

const Placeholder = () => {
return <p className={styles.editorPlaceholder}>{placeholder}</p>;
};

useEffect(() => {
return editor.registerCommand(
KEY_DOWN_COMMAND,
(event: KeyboardEvent) => {
let formatter = handleFormatterEvents(event);

editor.registerCommand(
FORMAT_TEXT_COMMAND,
(event: any) => {
editor.update(() => {
const selection = $getSelection();
if (selection?.getTextContent() && formatter) {
const text = handleFormatting(selection?.getTextContent(), formatter);
const text = handleFormatting(selection?.getTextContent(), event);

if (!selection?.getTextContent()) {
const newNode = $createTextNode(text);
selection?.insertNodes([newNode]);

const newSelection = $createRangeSelection();
newSelection.anchor.set(newNode.getKey(), 1, 'text');
newSelection.focus.set(newNode.getKey(), 1, 'text');
$setSelection(newSelection);
}
if (selection?.getTextContent() && event) {
const newNode = $createTextNode(text);
selection?.insertNodes([newNode]);
editor.focus();
}
});
return false;
Expand All @@ -71,49 +91,140 @@ export const Editor = ({ disabled = false, ...props }: EditorProps) => {
}
}, [disabled]);

useEffect(() => {
const checkFormatting = () => {
editor.update(() => {
const selection = $getSelection();

if (!$isRangeSelection(selection)) {
setActiveFormats({ bold: false, italic: false, strikethrough: false });
return;
}

const anchorNode = selection.anchor.getNode();
const anchorOffset = selection.anchor.offset;

if (!anchorNode.getTextContent()) {
setActiveFormats({ bold: false, italic: false, strikethrough: false });
return;
}

const textContent = anchorNode.getTextContent();

const boldRegex = /\*(?:\S.*?\S|\S)\*/g;
const italicRegex = /_(.*?)_/g;
const strikethroughRegex = /~(.*?)~/g;

const isInsideFormat = (regex: RegExp) => {
let match;
while ((match = regex.exec(textContent)) !== null) {
const start = match.index;
const end = start + match[0].length;
if (anchorOffset > start && anchorOffset < end) {
return true;
}
}
return false;
};

setActiveFormats({
bold: isInsideFormat(boldRegex),
italic: isInsideFormat(italicRegex),
strikethrough: isInsideFormat(strikethroughRegex),
});
});
};

// Register an update listener to track selection changes
const removeListener = editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
checkFormatting();
});
});

return () => {
removeListener();
};
}, [editor]);

const handleChange = (editorState: any) => {
editorState.read(() => {
const root = $getRoot();
if (!disabled) {
if (!disabled && onChange) {
onChange(root.getTextContent());
form?.setFieldValue(field?.name, root.getTextContent());
}
});
};

const Placeholder = () => {
return <p className={styles.editorPlaceholder}>{placeholder}</p>;
};

return (
<div className={styles.EditorWrapper}>
<div className={disabled ? styles?.disabled : styles.Editor} data-testid="resizer">
<PlainTextPlugin
placeholder={<Placeholder />}
contentEditable={
<div className={styles.editorScroller}>
<div className={styles.editor}>
<ContentEditable
data-testid={`editor-${field.name}`}
disabled={disabled}
className={styles.EditorInput}
/>
<>
<div className={styles.EditorWrapper}>
<div className={styles.FormatingOptions}>
<span
data-testid="bold-icon"
className={activeFormats.bold ? styles.Active : ''}
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
}}
>
<FormatBold fontSize="small" color="inherit" />
</span>
<span
data-testid="italic-icon"
className={activeFormats.italic ? styles.Active : ''}
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
}}
>
<FormatItalic fontSize="small" color="inherit" />
</span>
<span
data-testid="strikethrough-icon"
className={activeFormats.strikethrough ? styles.Active : ''}
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
}}
>
<StrikethroughS fontSize="small" color="inherit" />
</span>
</div>
<div className={disabled ? styles?.disabled : styles.Editor} data-testid="resizer">
<PlainTextPlugin
placeholder={<Placeholder />}
contentEditable={
<div className={styles.editorScroller}>
<div className={styles.editor}>
<ContentEditable
data-testid={`editor-${field.name}`}
disabled={disabled}
className={styles.EditorInput}
/>
</div>
</div>
</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
<BeautifulMentionsPlugin
menuComponent={CustomMenu}
menuItemComponent={CustomMenuItem}
triggers={['@']}
items={suggestions}
/>
<OnChangePlugin onChange={handleChange} />
{picker}
}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
<BeautifulMentionsPlugin
menuComponent={CustomMenu}
menuItemComponent={CustomMenuItem}
triggers={['@']}
items={suggestions}
/>
<OnChangePlugin onChange={handleChange} />
{picker}
</div>
</div>
{form && form.errors[field.name] && form.touched[field.name] ? (
<FormHelperText className={styles.DangerText}>{form.errors[field.name]}</FormHelperText>
) : null}
{props.helperText && <FormHelperText className={styles.HelperText}>{props.helperText}</FormHelperText>}
</div>
</>
);
};

Expand Down
1 change: 1 addition & 0 deletions src/components/UI/Form/EmojiInput/EmojiInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ const EmojiPickerComponent = ({ showEmojiPicker, setShowEmojiPicker, handleClick
selection.insertNodes([$createTextNode(emoji.native)]);
}
});
editor.focus();
}}
displayStyle={emojiStyles}
/>
Expand Down
9 changes: 7 additions & 2 deletions src/containers/HSM/HSM.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,15 @@ describe('Add mode', () => {

fireEvent.click(screen.getByText('ACCOUNT_UPDATE'), { key: 'Enter' });

fireEvent.click(screen.getByTestId('bold-icon'));

fireEvent.click(screen.getByText('Allow meta to re-categorize template?'));

fireEvent.click(screen.getByTestId('italic-icon'));
fireEvent.click(screen.getByTestId('strikethrough-icon'));

await waitFor(() => {
expect(screen.getByText('Hi, How are you')).toBeInTheDocument();
expect(screen.getByText('Hi, How are you**')).toBeInTheDocument();
});

fireEvent.click(screen.getByText('Add Variable'));
Expand All @@ -155,7 +160,7 @@ describe('Add mode', () => {
});

await waitFor(() => {
expect(screen.getByText('Hi, How are you {{1}}')).toBeInTheDocument();
expect(screen.getByText('Hi, How are you** {{1}}')).toBeInTheDocument();
});

fireEvent.change(screen.getByPlaceholderText('Define value'), { target: { value: 'User' } });
Expand Down
6 changes: 3 additions & 3 deletions src/mocks/Template.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -270,16 +270,16 @@ export const createTemplateMock = (input: any) => ({

export const templateMock = createTemplateMock({
label: 'title',
body: 'Hi, How are you {{1}}',
body: 'Hi, How are you*_~~_* {{1}}',
type: 'TEXT',
category: 'ACCOUNT_UPDATE',
tagId: '1',
isActive: true,
allowTemplateCategoryChange: false,
isHsm: true,
languageId: '1',
example: 'Hi, How are you [User]',
shortcode: 'element_name',
languageId: '1',
example: 'Hi, How are you*_~~_* [User]',
hasButtons: true,
buttons: '[{"type":"PHONE_NUMBER","text":"Call me","phone_number":"9876543210"}]',
buttonType: 'CALL_TO_ACTION',
Expand Down
2 changes: 1 addition & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6458,4 +6458,4 @@ zen-observable-ts@^1.2.5:
zen-observable@0.8.15:
version "0.8.15"
resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.15.tgz#96415c512d8e3ffd920afd3889604e30b9eaac15"
integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==
integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==

0 comments on commit 6d381d2

Please sign in to comment.