Skip to content

Commit

Permalink
feat: add emoji plugin (#3653)
Browse files Browse the repository at this point in the history
  • Loading branch information
TomasSlama authored and dmaklygin committed Jul 5, 2023
1 parent 7fae761 commit ed8020e
Show file tree
Hide file tree
Showing 17 changed files with 426 additions and 22 deletions.
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,32 @@ 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 { noop } from '@toptal/picasso/utils'
import { Container, Typography } from '@toptal/picasso'
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'
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 @@ -67,8 +74,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 @@ -77,15 +83,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 @@ -107,7 +115,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 @@ -127,7 +135,7 @@ const LexicalEditor = forwardRef<HTMLDivElement, Props>(function LexicalEditor(
typographyClassNames,
classes,
}),
[typographyClassNames, classes.paragraph]
[typographyClassNames, classes]
)

const editorConfig: InitialConfigType = useMemo(
Expand All @@ -142,7 +150,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 @@ -177,6 +185,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 @@ -187,6 +197,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-rich-text-editor/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-rich-text-editor/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 {
synchronizeToolbarState,
toolbarStateReducer,
} from '../LexicalEditor/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
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

0 comments on commit ed8020e

Please sign in to comment.