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
96 changes: 96 additions & 0 deletions src/addons/uiblocks/src/core/primitives/TextWithEmoji.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import {describe, it, expect} from 'vitest';
import {TextWithEmoji} from './TextWithEmoji';
import {Text, Image, Container} from '@pmndrs/uikit';

describe('TextWithEmoji Primitives', () => {
it('should parse plain text and spaces correctly', () => {
const parent = new Container();
const textWithEmoji = new TextWithEmoji({
text: 'Hello World',
fontSize: 16,
});
parent.add(textWithEmoji);

// Should have: Text('Hello'), Container(space), Text('World')
expect(textWithEmoji.children).toHaveLength(3);
expect(textWithEmoji.children[0]).toBeInstanceOf(Text);
expect(textWithEmoji.children[1]).toBeInstanceOf(Container);
expect(textWithEmoji.children[2]).toBeInstanceOf(Text);
});

it('should parse and render emojis correctly', () => {
const parent = new Container();
const textWithEmoji = new TextWithEmoji({
text: 'Hello 🚀 World',
fontSize: 16,
});
parent.add(textWithEmoji);

// Should have: Text('Hello'), Container(space), Image(emoji), Container(space), Text('World')
expect(textWithEmoji.children).toHaveLength(5);
expect(textWithEmoji.children[0]).toBeInstanceOf(Text);
expect(textWithEmoji.children[1]).toBeInstanceOf(Container);
expect(textWithEmoji.children[2]).toBeInstanceOf(Image);
expect(textWithEmoji.children[3]).toBeInstanceOf(Container);
expect(textWithEmoji.children[4]).toBeInstanceOf(Text);
});

it('should handle single newline correctly', () => {
const parent = new Container();
const textWithEmoji = new TextWithEmoji({
text: 'Hello\nWorld',
fontSize: 16,
});
parent.add(textWithEmoji);

expect(textWithEmoji.children).toHaveLength(3);
expect(textWithEmoji.children[0]).toBeInstanceOf(Text);
expect(textWithEmoji.children[1]).toBeInstanceOf(Container);
expect(textWithEmoji.children[2]).toBeInstanceOf(Text);

const newlineContainer = textWithEmoji.children[1] as Container;
expect(newlineContainer.properties.value.width).toBe('100%');
expect(newlineContainer.properties.value.height).toBe(0);
});

it('should handle double newlines correctly', () => {
const parent = new Container();
const textWithEmoji = new TextWithEmoji({
text: 'Hello\n\nWorld',
fontSize: 16,
});
parent.add(textWithEmoji);

expect(textWithEmoji.children).toHaveLength(4);
expect(textWithEmoji.children[0]).toBeInstanceOf(Text);
expect(textWithEmoji.children[1]).toBeInstanceOf(Container);
expect(textWithEmoji.children[2]).toBeInstanceOf(Container);
expect(textWithEmoji.children[3]).toBeInstanceOf(Text);

const newline1 = textWithEmoji.children[1] as Container;
const newline2 = textWithEmoji.children[2] as Container;

expect(newline1.properties.value.width).toBe('100%');
expect(newline1.properties.value.height).toBe(0);

expect(newline2.properties.value.width).toBe('100%');
expect(newline2.properties.value.height).toBe(16); // matches fontSize 16
});

it('should handle leading newline correctly', () => {
const parent = new Container();
const textWithEmoji = new TextWithEmoji({
text: '\nHello',
fontSize: 16,
});
parent.add(textWithEmoji);

expect(textWithEmoji.children).toHaveLength(2);
expect(textWithEmoji.children[0]).toBeInstanceOf(Container);
expect(textWithEmoji.children[1]).toBeInstanceOf(Text);

const newline = textWithEmoji.children[0] as Container;
expect(newline.properties.value.width).toBe('100%');
expect(newline.properties.value.height).toBe(16); // matches fontSize 16
});
});
36 changes: 30 additions & 6 deletions src/addons/uiblocks/src/core/primitives/TextWithEmoji.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export type TextWithEmojiProperties = InProperties<TextWithEmojiOutProperties>;
// It matches all emoji presentation sequences (including warning signs, hearts, and sparkles)
// and groups Variation Selectors (\uFE0F), ZWJ Joiners (\u200D), and modifiers with their parent emoji.
const WORD_EMOJI_REGEX =
/(?:\p{Emoji_Presentation}|\p{Emoji}\uFE0F)(?:\u200D(?:\p{Emoji_Presentation}|\p{Emoji}\uFE0F))*(?:\p{Emoji_Modifier})*|\s+|[a-zA-Z0-9]+|[^a-zA-Z0-9\s]/gu;
/(?:\p{Emoji_Presentation}|\p{Emoji}\uFE0F)(?:\u200D(?:\p{Emoji_Presentation}|\p{Emoji}\uFE0F))*(?:\p{Emoji_Modifier})*|\n|[ \t\r]+|[a-zA-Z0-9]+|[^a-zA-Z0-9\s]/gu;

function getEmojiHex(emoji: string): string {
let hex = Array.from(emoji)
Expand Down Expand Up @@ -122,7 +122,10 @@ export class TextWithEmoji extends Container<TextWithEmojiOutProperties> {

// Reactively rebuild children when the text or sizing properties change
this.cleanupEffect = effect(() => {
const currentText = this.properties.value.text ?? '';
const currentText = (this.properties.value.text ?? '').replace(
/\r\n/g,
'\n'
);
const currentFontSize = this.properties.value.fontSize ?? 16;
const emojiCdn = (this.properties.value.emojiCdn ?? 'twemoji') as
| 'twemoji'
Expand All @@ -136,11 +139,20 @@ export class TextWithEmoji extends Container<TextWithEmojiOutProperties> {
// Parse text into active structural segment tokens
const segments = currentText.match(WORD_EMOJI_REGEX) || [];
const activeSegments: Array<{
type: 'space' | 'emoji' | 'word';
type: 'space' | 'newline' | 'emoji' | 'word';
text: string;
isConsecutiveNewline?: boolean;
}> = [];
for (const segment of segments) {
if (/^\s+$/.test(segment)) {
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
if (segment === '\n') {
const isConsecutiveNewline = i === 0 || segments[i - 1] === '\n';
activeSegments.push({
type: 'newline',
text: segment,
isConsecutiveNewline,
});
} else if (/^[ \t\r]+$/.test(segment)) {
activeSegments.push({type: 'space', text: segment});
} else if (
/(?:\p{Emoji_Presentation}|\p{Emoji}\uFE0F)/u.test(segment)
Expand All @@ -161,7 +173,7 @@ export class TextWithEmoji extends Container<TextWithEmojiOutProperties> {
const child = this.children[i];
const seg = activeSegments[i];
if (
seg.type === 'space' &&
(seg.type === 'space' || seg.type === 'newline') &&
!(
child instanceof Container &&
!(child instanceof Image) &&
Expand Down Expand Up @@ -193,6 +205,12 @@ export class TextWithEmoji extends Container<TextWithEmojiOutProperties> {
width: currentFontSize * 0.26 * seg.text.length,
height: currentFontSize,
});
} else if (seg.type === 'newline') {
const newlineContainer = child as Container;
newlineContainer.setProperties({
width: '100%',
height: seg.isConsecutiveNewline ? currentFontSize : 0,
});
} else if (seg.type === 'emoji') {
const img = child as Image;
img.setProperties({
Expand Down Expand Up @@ -240,6 +258,12 @@ export class TextWithEmoji extends Container<TextWithEmojiOutProperties> {
height: currentFontSize,
});
this.add(spaceContainer);
} else if (seg.type === 'newline') {
const newlineContainer = new Container({
width: '100%',
height: seg.isConsecutiveNewline ? currentFontSize : 0,
});
this.add(newlineContainer);
} else if (seg.type === 'emoji') {
const img = new Image({
src: getEmojiUrl(seg.text, emojiCdn),
Expand Down