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;
};