diff --git a/__tests__/components/drops/create/lexical/plugins/emoji/EmojiPlugin.extra.test.tsx b/__tests__/components/drops/create/lexical/plugins/emoji/EmojiPlugin.extra.test.tsx deleted file mode 100644 index 0164231bec..0000000000 --- a/__tests__/components/drops/create/lexical/plugins/emoji/EmojiPlugin.extra.test.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { render } from '@testing-library/react'; -import EmojiPlugin, { EMOJI_MATCH_REGEX } from '../../../../../../../components/drops/create/lexical/plugins/emoji/EmojiPlugin'; -import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; - -jest.mock('@lexical/react/LexicalComposerContext', () => ({ useLexicalComposerContext: jest.fn() })); - -jest.mock("../../../../../../../components/drops/create/lexical/nodes/EmojiNode", () => class {}); -jest.mock('lexical', () => ({ - $getRoot: jest.fn(() => ({ getAllTextNodes: () => [] })), - $getSelection: jest.fn(() => null), - $isRangeSelection: jest.fn(() => false), - $createRangeSelection: jest.fn(() => ({ anchor: { set: jest.fn() }, focus: { set: jest.fn() } })), - $setSelection: jest.fn(), - TextNode: class {}, -})); - -describe('EmojiPlugin extra', () => { - it('does not update when listener text has no colon', () => { - const update = jest.fn(fn => fn()); - const register = jest.fn(); - (useLexicalComposerContext as jest.Mock).mockReturnValue([{ update, registerTextContentListener: register }]); - render(); - const cb = register.mock.calls[0][0]; - cb('nothing'); - expect(update).toHaveBeenCalledTimes(1); - }); - - it('regex captures id without colons', () => { - const match = ':smile:'.match(EMOJI_MATCH_REGEX); - expect(match?.[0]).toBe(':smile:'); - }); -}); diff --git a/__tests__/components/drops/create/lexical/plugins/emoji/EmojiPlugin.test.tsx b/__tests__/components/drops/create/lexical/plugins/emoji/EmojiPlugin.test.tsx index 4e597efe88..003ec297c7 100644 --- a/__tests__/components/drops/create/lexical/plugins/emoji/EmojiPlugin.test.tsx +++ b/__tests__/components/drops/create/lexical/plugins/emoji/EmojiPlugin.test.tsx @@ -1,53 +1,125 @@ -import { render } from '@testing-library/react'; import React from 'react'; +import { render } from '@testing-library/react'; import EmojiPlugin, { EMOJI_MATCH_REGEX } from '../../../../../../../components/drops/create/lexical/plugins/emoji/EmojiPlugin'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { useEmoji } from '../../../../../../../contexts/EmojiContext'; -jest.mock('@lexical/react/LexicalComposerContext'); - -// minimal lexical mocks -jest.mock('lexical', () => ({ - $getRoot: jest.fn(() => ({ getAllTextNodes: () => [] })), - $getSelection: jest.fn(() => null), - $isRangeSelection: jest.fn(() => false), - $createRangeSelection: jest.fn(() => ({ anchor: { set: jest.fn() }, focus: { set: jest.fn() } })), - $setSelection: jest.fn(), - TextNode: class { - private text: string; - constructor(text: string) { this.text = text; } - getTextContent() { return this.text; } - setTextContent(_t: string) { this.text = _t; } - insertAfter() {} - remove() {} - getKey() { return 'k'; } - }, - LexicalEditor: class {}, +jest.mock('@lexical/react/LexicalComposerContext', () => ({ + useLexicalComposerContext: jest.fn(), })); jest.mock('../../../../../../../components/drops/create/lexical/nodes/EmojiNode', () => ({ EmojiNode: class {}, })); +jest.mock('../../../../../../../contexts/EmojiContext', () => ({ + useEmoji: jest.fn(), +})); + +jest.mock('lexical', () => { + class MockTextNode { + private text: string; + + constructor(text: string) { + this.text = text; + } + + getTextContent() { + return this.text; + } + + setTextContent(text: string) { + this.text = text; + } + + insertAfter() {} + + remove() {} + + getKey() { + return 'key'; + } + } + + return { + $getRoot: jest.fn(() => ({ getAllTextNodes: () => [] })), + $getSelection: jest.fn(() => null), + $isRangeSelection: jest.fn(() => false), + $createRangeSelection: jest.fn(() => ({ + anchor: { set: jest.fn() }, + focus: { set: jest.fn() }, + })), + $setSelection: jest.fn(), + TextNode: MockTextNode, + LexicalEditor: class {}, + }; +}); + const useContextMock = useLexicalComposerContext as jest.Mock; +const useEmojiMock = useEmoji as jest.Mock; + +beforeEach(() => { + useEmojiMock.mockReturnValue({ + emojiMap: [ + { + id: 'custom', + name: 'custom', + category: 'custom', + emojis: [{ id: 'smile', name: 'Smile', keywords: 'happy', skins: [{ src: '' }] }], + }, + ], + findNativeEmoji: jest.fn(() => null), + }); +}); describe('EmojiPlugin', () => { it('matches emoji regex correctly', () => { const text = 'say :smile: and :joy:'; - const matches = Array.from(text.matchAll(EMOJI_MATCH_REGEX)).map(m => m[1]); + const matches = Array.from(text.matchAll(EMOJI_MATCH_REGEX)).map((match) => match[1]); expect(matches).toEqual(['smile', 'joy']); }); it('calls update when emoji text detected', () => { - let listener: (t: string) => void = () => {}; - const update = jest.fn((cb: any) => cb()); + let listener: (text: string) => void = () => undefined; + const update = jest.fn((callback: () => void) => callback()); const editor = { update, - registerTextContentListener: jest.fn((cb: any) => { listener = cb; return () => {}; }) + registerTextContentListener: jest.fn((cb: typeof listener) => { + listener = cb; + return () => undefined; + }), } as any; + useContextMock.mockReturnValue([editor]); render(); + expect(update).toHaveBeenCalledTimes(1); + listener('hello :smile:'); expect(update).toHaveBeenCalledTimes(2); }); + + it('does not update when listener text has no colon', () => { + const update = jest.fn((callback: () => void) => callback()); + const register = jest.fn(() => () => undefined); + + useContextMock.mockReturnValue([ + { + update, + registerTextContentListener: register, + }, + ]); + + render(); + + const listener = register.mock.calls[0][0] as (text: string) => void; + listener('nothing'); + + expect(update).toHaveBeenCalledTimes(1); + }); + + it('regex captures id including surrounding colons', () => { + const match = ':smile:'.match(EMOJI_MATCH_REGEX); + expect(match?.[0]).toBe(':smile:'); + }); }); diff --git a/components/drops/create/lexical/plugins/emoji/EmojiPlugin.ts b/components/drops/create/lexical/plugins/emoji/EmojiPlugin.ts index 8b39efd2d1..cd1740fa41 100644 --- a/components/drops/create/lexical/plugins/emoji/EmojiPlugin.ts +++ b/components/drops/create/lexical/plugins/emoji/EmojiPlugin.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { $getRoot, @@ -12,11 +12,15 @@ import { LexicalEditor, } from "lexical"; import { EmojiNode } from "../../nodes/EmojiNode"; +import { useEmoji } from "../../../../../../contexts/EmojiContext"; const EMOJI_TEST_REGEX = /:(\w+)/; export const EMOJI_MATCH_REGEX = /:(\w+):/g; -function transformEmojiTextToNode(editor: LexicalEditor) { +function transformEmojiTextToNode( + editor: LexicalEditor, + isEmojiIdValid: (emojiId: string) => boolean +) { editor.update(() => { const selectionBefore = $getSelection(); let anchorNodeKey: string | null = null; @@ -31,27 +35,38 @@ function transformEmojiTextToNode(editor: LexicalEditor) { const root = $getRoot(); const textNodes = root.getAllTextNodes(); - textNodes.forEach((node) => { + for (const node of textNodes) { const textContent = node.getTextContent(); if (!EMOJI_TEST_REGEX.test(textContent)) { - return; + continue; } - const matches = Array.from(textContent.matchAll(EMOJI_MATCH_REGEX)); + const matches = Array.from(textContent.matchAll(EMOJI_MATCH_REGEX)).map( + (match) => ({ + matchText: match[0], + emojiId: match[1], + startIndex: match.index!, + endIndex: match.index! + match[0].length, + }) + ); + if (matches.length === 0) { return; } + const hasValidEmoji = matches.some(({ emojiId }) => + isEmojiIdValid(emojiId) + ); + + if (!hasValidEmoji) { + return; + } + let lastIndex = 0; const newNodes: (TextNode | EmojiNode)[] = []; let cursorNode: TextNode | null = null; - matches.forEach((match) => { - const emojiText = match[0]; - const emojiId = match[1]; - const startIndex = match.index!; - const endIndex = startIndex + emojiText.length; - + for (const { matchText, emojiId, startIndex, endIndex } of matches) { if (startIndex > lastIndex) { const beforeStr = textContent.slice(lastIndex, startIndex); if (beforeStr.length > 0) { @@ -59,6 +74,12 @@ function transformEmojiTextToNode(editor: LexicalEditor) { } } + if (!isEmojiIdValid(emojiId)) { + newNodes.push(new TextNode(matchText)); + lastIndex = endIndex; + return; + } + const emojiNode = new EmojiNode(emojiId); newNodes.push(emojiNode); @@ -74,11 +95,11 @@ function transformEmojiTextToNode(editor: LexicalEditor) { } lastIndex = endIndex; - }); + } if (lastIndex < textContent.length) { const afterStr = textContent.slice(lastIndex); - let lastCreated = newNodes[newNodes.length - 1]; + const lastCreated = newNodes.at(-1); if (lastCreated instanceof TextNode) { lastCreated.setTextContent(lastCreated.getTextContent() + afterStr); } else { @@ -87,10 +108,10 @@ function transformEmojiTextToNode(editor: LexicalEditor) { } let prev: TextNode | EmojiNode = node; - newNodes.forEach((n) => { - prev.insertAfter(n); - prev = n; - }); + for (const newNode of newNodes) { + prev.insertAfter(newNode); + prev = newNode; + } node.remove(); @@ -101,22 +122,44 @@ function transformEmojiTextToNode(editor: LexicalEditor) { newSelection.focus.set(cursorTextNodeKey, 0, "text"); $setSelection(newSelection); } - }); + } }); } const EmojiPlugin = () => { const [editor] = useLexicalComposerContext(); + const { emojiMap, findNativeEmoji } = useEmoji(); + + const customEmojiIds = useMemo(() => { + const ids = new Set(); + emojiMap.forEach((category) => { + category.emojis.forEach((emoji) => { + ids.add(emoji.id); + }); + }); + return ids; + }, [emojiMap]); + + const isEmojiIdValid = useCallback( + (emojiId: string) => { + if (customEmojiIds.has(emojiId)) { + return true; + } + + return Boolean(findNativeEmoji(emojiId)); + }, + [customEmojiIds, findNativeEmoji] + ); useEffect(() => { - transformEmojiTextToNode(editor); + transformEmojiTextToNode(editor, isEmojiIdValid); return editor.registerTextContentListener((textContent) => { if (EMOJI_TEST_REGEX.test(textContent)) { - transformEmojiTextToNode(editor); + transformEmojiTextToNode(editor, isEmojiIdValid); } }); - }, [editor]); + }, [editor, isEmojiIdValid]); return null; };