Skip to content
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

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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(<EmojiPlugin />);

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(<EmojiPlugin />);

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:');
});
});
85 changes: 64 additions & 21 deletions components/drops/create/lexical/plugins/emoji/EmojiPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect } from "react";
import { useCallback, useEffect, useMemo } from "react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import {
$getRoot,
Expand All @@ -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;
Expand All @@ -31,34 +35,51 @@ 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) {
newNodes.push(new TextNode(beforeStr));
}
}

if (!isEmojiIdValid(emojiId)) {
newNodes.push(new TextNode(matchText));
lastIndex = endIndex;
return;
}

Comment on lines 53 to +82
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Don't abort processing when encountering invalid emoji IDs

These new return statements exit transformEmojiTextToNode entirely as soon as the current text node lacks a valid emoji or the loop hits one invalid ID. In practice, a leading :foo: (unknown) completely prevents later :smile: matches—in the same node or subsequent nodes—from ever converting, so the colon-jump bug persists. We need to skip the offending segment/node but keep scanning the rest.

-      if (matches.length === 0) {
-        return;
-      }
+      if (matches.length === 0) {
+        continue;
+      }
@@
-      if (!hasValidEmoji) {
-        return;
-      }
+      if (!hasValidEmoji) {
+        continue;
+      }
@@
-        if (!isEmojiIdValid(emojiId)) {
-          newNodes.push(new TextNode(matchText));
-          lastIndex = endIndex;
-          return;
-        }
+        if (!isEmojiIdValid(emojiId)) {
+          newNodes.push(new TextNode(matchText));
+          lastIndex = endIndex;
+          continue;
+        }

Committable suggestion skipped: line range outside the PR's diff.

const emojiNode = new EmojiNode(emojiId);
newNodes.push(emojiNode);

Expand All @@ -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 {
Expand All @@ -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();

Expand All @@ -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<string>();
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;
};
Expand Down