Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(RichTextEditor): add Emoji plugin #3653

Merged
merged 1 commit into from
Jun 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/picasso/src/LexicalEditor/LexicalEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import LexicalTextLengthPlugin from '../LexicalTextLengthPlugin'
import LexicalHeadingsReplacementPlugin from '../LexicalHeadingsReplacementPlugin'
import ToolbarPlugin from '../LexicalEditorToolbarPlugin'
import LexicalListPlugin from '../LexicalListPlugin'
import type { CustomEmojiGroup } from './types'

jest.mock('../LexicalEditorToolbarPlugin', () => ({
__esModule: true,
Expand All @@ -18,6 +19,10 @@ jest.mock('../LexicalListPlugin', () => ({
__esModule: true,
default: jest.fn(() => <div>LexicalListPlugin</div>),
}))
jest.mock('../LexicalEmojiPlugin', () => ({
__esModule: true,
default: jest.fn(() => <div>LexicalEmojiPlugin</div>),
}))

jest.mock('@lexical/react/LexicalComposerContext', () => ({
__esModule: true,
Expand Down Expand Up @@ -136,6 +141,8 @@ describe('LexicalEditor', () => {
expect(mockedToolbarPlugin).toHaveBeenCalledWith(
{
disabled: true,
customEmojis: undefined,
plugins: [],
toolbarRef: {
current: null,
},
Expand All @@ -152,6 +159,30 @@ describe('LexicalEditor', () => {
expect(mockedToolbarPlugin).toHaveBeenCalledWith(
{
disabled: true,
customEmojis: undefined,
plugins: [],
toolbarRef: {
current: null,
},
},
{}
)
})
})

describe('when customEmojis and plugins prop is passed', () => {
it('renders ToolbarPlugin with correct props', () => {
renderLexicalEditor({
disabled: true,
customEmojis: ['foo' as unknown as CustomEmojiGroup],
plugins: ['link'],
})

expect(mockedToolbarPlugin).toHaveBeenCalledWith(
{
disabled: true,
customEmojis: ['foo'],
plugins: ['link'],
toolbarRef: {
current: null,
},
Expand Down
31 changes: 21 additions & 10 deletions packages/picasso/src/LexicalEditor/LexicalEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin'
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary'
import { HeadingNode } from '@lexical/rich-text'
import { $generateHtmlFromNodes } from '@lexical/html'
import { ListItemNode, ListNode } from '@lexical/list'
import { $isRootTextContentEmpty } from '@lexical/text'
import type { LexicalEditor as LexicalEditorType } from 'lexical'
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
import { ListItemNode, ListNode } from '@lexical/list'
import { HeadingNode } from '@lexical/rich-text'

import { TriggerInitialOnChangePlugin } from './plugins'
import { createLexicalTheme, setEditorValue } from './utils'
Expand All @@ -22,12 +22,19 @@ import Container from '../Container'
import Typography from '../Typography'
import { useTypographyClasses, useOnFocus } from './hooks'
import styles from './styles'
import type { ChangeHandler, TextLengthChangeHandler } from './types'
import type {
ChangeHandler,
EditorPlugin,
TextLengthChangeHandler,
CustomEmojiGroup,
} from './types'
import ToolbarPlugin from '../LexicalEditorToolbarPlugin'
import LexicalTextLengthPlugin from '../LexicalTextLengthPlugin'
import LexicalListPlugin from '../LexicalListPlugin'
import LexicalHeadingsReplacementPlugin from '../LexicalHeadingsReplacementPlugin'
import type { ASTType } from '../RichText'
import { CustomEmojiNode } from '../LexicalEmojiPlugin/nodes/CustomEmojiNode'
import LexicalEmojiPlugin from '../LexicalEmojiPlugin'

const useStyles = makeStyles<Theme>(styles, {
name: 'LexicalEditor',
Expand Down Expand Up @@ -68,8 +75,7 @@ export type Props = BaseProps & {
onTextLengthChange: TextLengthChangeHandler
/** The placeholder attribute specifies a short hint that describes the expected value of a text editor. */
placeholder?: string
/** List of plugins to enable on the editor */
// plugins?: EditorPlugin[]

testIds?: {
editor?: string
// headerSelect?: string
Expand All @@ -78,15 +84,17 @@ export type Props = BaseProps & {
// unorderedListButton?: string
// orderedListButton?: string
}
// customEmojis?: CustomEmojiGroup[]
customEmojis?: CustomEmojiGroup[]
/** List of plugins to enable on the editor */
plugins?: EditorPlugin[]
}

const LexicalEditor = forwardRef<HTMLDivElement, Props>(function LexicalEditor(
props,
ref
) {
const {
// plugins,
plugins = [],
autoFocus = false,
defaultValue,
disabled = false,
Expand All @@ -108,7 +116,7 @@ const LexicalEditor = forwardRef<HTMLDivElement, Props>(function LexicalEditor(
// @todo don't know what to do with NAME prop
// name,
// highlight,
// customEmojis,
customEmojis,
} = props

const classes = useStyles()
Expand All @@ -128,7 +136,7 @@ const LexicalEditor = forwardRef<HTMLDivElement, Props>(function LexicalEditor(
typographyClassNames,
classes,
}),
[typographyClassNames, classes.paragraph]
[typographyClassNames, classes]
)

const editorConfig: InitialConfigType = useMemo(
Expand All @@ -143,7 +151,7 @@ const LexicalEditor = forwardRef<HTMLDivElement, Props>(function LexicalEditor(
throw error
},
namespace: 'editor',
nodes: [ListNode, ListItemNode, HeadingNode],
nodes: [CustomEmojiNode, ListNode, ListItemNode, HeadingNode],
editable: !disabled,
}),
[defaultValue, theme, disabled]
Expand Down Expand Up @@ -178,6 +186,8 @@ const LexicalEditor = forwardRef<HTMLDivElement, Props>(function LexicalEditor(
toolbarRef={toolbarRef}
// remount Toolbar when disabled
key={`${disabled || !isFocused}`}
customEmojis={customEmojis}
plugins={plugins}
/>
{defaultValue ? (
<TriggerInitialOnChangePlugin onChange={handleChange} />
Expand All @@ -188,6 +198,7 @@ const LexicalEditor = forwardRef<HTMLDivElement, Props>(function LexicalEditor(
<LexicalHeadingsReplacementPlugin />
<LexicalTextLengthPlugin onTextLengthChange={onTextLengthChange} />
<LexicalListPlugin />
<LexicalEmojiPlugin />

<div className={classes.editorContainer} id={id} ref={ref}>
<RichTextPlugin
Expand Down
7 changes: 7 additions & 0 deletions packages/picasso/src/LexicalEditor/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,5 +153,12 @@ export default ({ typography }: Theme) => {
italic: {
fontStyle: 'italic',
},
customEmoji: {
'& > img': {
verticalAlign: 'bottom',
width: '22px',
height: '22px',
},
},
})
}
32 changes: 32 additions & 0 deletions packages/picasso/src/LexicalEditor/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,35 @@
export type SettingName = 'link' | 'emoji'

export type ChangeHandler = (html: string) => void

export type { TextLengthChangeHandler } from '../LexicalTextLengthPlugin'

export type EditorPlugin = SettingName

export type CustomEmoji = {
id: string
name: string
keywords: string[]
skins: [
{
src: string
}
]
}

export type CustomEmojiGroup = {
id: string
name: string
emojis: CustomEmoji[]
}

export type Emoji = {
id: string
name: string
native?: string
unified?: string
keywords: string[]
shortcodes: string
emoticons?: string[]
src?: string
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const createLexicalTheme = ({
ul: classes.ul,
ol: classes.ol,
},
customEmoji: classes.customEmoji,
}

return theme
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,32 @@ import {
toolbarStateReducer,
} from '../LexicalEditor/utils'
import { noop } from '../utils'
import type {
CustomEmojiGroup,
EditorPlugin,
Emoji,
} from '../LexicalEditor/types'
import {
INSERT_CUSTOM_EMOJI_COMMAND,
INSERT_EMOJI_COMMAND,
} from '../LexicalEmojiPlugin/commands'
import type { HeaderValue } from '../RichTextEditorToolbar'
import RichTextEditorToolbar, {
ALLOWED_HEADER_TYPE,
} from '../RichTextEditorToolbar'

type Props = {
customEmojis?: CustomEmojiGroup[]
disabled?: boolean
toolbarRef: React.RefObject<HTMLDivElement>
plugins?: EditorPlugin[]
}

const LexicalEditorToolbarPlugin = ({
disabled = false,
toolbarRef,
customEmojis,
plugins,
}: Props) => {
const [editor] = useLexicalComposerContext()
const [{ bold, italic, list, header }, dispatch] = useReducer(
Expand Down Expand Up @@ -73,6 +86,24 @@ const LexicalEditorToolbarPlugin = ({
)
}

const handleInsertEmoji = (emoji: Emoji) => {
const isNativeEmoji = emoji.native
const isCustomEmoji = emoji.src

if (isNativeEmoji) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
editor.dispatchCommand(INSERT_EMOJI_COMMAND, emoji.native!)
}

if (isCustomEmoji) {
editor.dispatchCommand(INSERT_CUSTOM_EMOJI_COMMAND, {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
src: emoji.src!,
id: emoji.id,
})
}
}

const handleHeaderClick = ({
target: { value },
}: ChangeEvent<{
Expand Down Expand Up @@ -108,8 +139,10 @@ const LexicalEditorToolbarPlugin = ({
onLinkClick={noop}
onHeaderChange={handleHeaderClick}
disabled={disabled}
onInsertEmoji={noop}
onInsertEmoji={handleInsertEmoji}
ref={toolbarRef}
customEmojis={customEmojis}
plugins={plugins}
/>
)
}
Expand Down
77 changes: 77 additions & 0 deletions packages/picasso/src/LexicalEmojiPlugin/LexicalEmojiPlugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { renderHook } from '@testing-library/react-hooks'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $createTextNode, $insertNodes, COMMAND_PRIORITY_EDITOR } from 'lexical'

import LexicalEmojiPlugin, {
INSERT_CUSTOM_EMOJI_COMMAND,
INSERT_EMOJI_COMMAND,
} from './index'
import { $createCustomEmojiNode } from './nodes/CustomEmojiNode'

jest.mock('@lexical/react/LexicalComposerContext', () => ({
useLexicalComposerContext: jest.fn(() => [{}]),
}))

jest.mock('@lexical/utils', () => ({
mergeRegister: jest.fn(),
}))

jest.mock('lexical', () => ({
$createTextNode: jest.fn(),
$insertNodes: jest.fn(),
COMMAND_PRIORITY_EDITOR: jest.fn(),
createCommand: jest.fn(),
}))

jest.mock('./nodes/CustomEmojiNode', () => ({
$createCustomEmojiNode: jest.fn(),
}))

describe('LexicalEmojiPlugin', () => {
const mockEditor = {
registerCommand: jest.fn(),
}

beforeEach(() => {
jest.resetAllMocks()
;(useLexicalComposerContext as jest.Mock).mockReturnValue([mockEditor])
})

it('registers commands on mount', () => {
renderHook(() => LexicalEmojiPlugin())

expect(mockEditor.registerCommand).toHaveBeenCalledTimes(2)
expect(mockEditor.registerCommand).toHaveBeenCalledWith(
INSERT_EMOJI_COMMAND,
expect.any(Function),
COMMAND_PRIORITY_EDITOR
)
expect(mockEditor.registerCommand).toHaveBeenCalledWith(
INSERT_CUSTOM_EMOJI_COMMAND,
expect.any(Function),
COMMAND_PRIORITY_EDITOR
)
})

it('inserts a text node when the native emoji command is called', () => {
renderHook(() => LexicalEmojiPlugin())
const nativeEmojiCommand = mockEditor.registerCommand.mock.calls[0][1]

nativeEmojiCommand('😃')

expect($createTextNode).toHaveBeenCalledWith('😃')
expect($insertNodes).toHaveBeenCalledWith([$createTextNode()])
})

it('inserts a custom emoji node when the custom emoji command is called', () => {
const payload = { id: 'custom emoji', src: 'https://example.com/emoji.png' }

renderHook(() => LexicalEmojiPlugin())
const customEmojiCommand = mockEditor.registerCommand.mock.calls[1][1]

customEmojiCommand(payload)

expect($createCustomEmojiNode).toHaveBeenCalledWith(payload)
expect($insertNodes).toHaveBeenCalledWith([$createCustomEmojiNode(payload)])
})
})
Loading
Loading