Skip to content

Commit 9bd9b7c

Browse files
committed
fix(uiblocks): support newlines in TextWithEmoji primitive
- Updates WORD_EMOJI_REGEX to separate newlines from standard spacing. - Renders newlines as 100% width flex Containers. - Assigns zero height to single newlines (forces clean wrap) and full line-height to consecutive newlines (renders empty vertical spacing). - Adds unit tests validating newline layout configurations.
1 parent ae5f85f commit 9bd9b7c

2 files changed

Lines changed: 123 additions & 6 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {describe, it, expect} from 'vitest';
2+
import {TextWithEmoji} from './TextWithEmoji';
3+
import {Text, Image, Container} from '@pmndrs/uikit';
4+
5+
describe('TextWithEmoji Primitives', () => {
6+
it('should parse plain text and spaces correctly', () => {
7+
const parent = new Container();
8+
const textWithEmoji = new TextWithEmoji({
9+
text: 'Hello World',
10+
fontSize: 16,
11+
});
12+
parent.add(textWithEmoji);
13+
14+
// Should have: Text('Hello'), Container(space), Text('World')
15+
expect(textWithEmoji.children).toHaveLength(3);
16+
expect(textWithEmoji.children[0]).toBeInstanceOf(Text);
17+
expect(textWithEmoji.children[1]).toBeInstanceOf(Container);
18+
expect(textWithEmoji.children[2]).toBeInstanceOf(Text);
19+
});
20+
21+
it('should parse and render emojis correctly', () => {
22+
const parent = new Container();
23+
const textWithEmoji = new TextWithEmoji({
24+
text: 'Hello 🚀 World',
25+
fontSize: 16,
26+
});
27+
parent.add(textWithEmoji);
28+
29+
// Should have: Text('Hello'), Container(space), Image(emoji), Container(space), Text('World')
30+
expect(textWithEmoji.children).toHaveLength(5);
31+
expect(textWithEmoji.children[0]).toBeInstanceOf(Text);
32+
expect(textWithEmoji.children[1]).toBeInstanceOf(Container);
33+
expect(textWithEmoji.children[2]).toBeInstanceOf(Image);
34+
expect(textWithEmoji.children[3]).toBeInstanceOf(Container);
35+
expect(textWithEmoji.children[4]).toBeInstanceOf(Text);
36+
});
37+
38+
it('should handle single newline correctly', () => {
39+
const parent = new Container();
40+
const textWithEmoji = new TextWithEmoji({
41+
text: 'Hello\nWorld',
42+
fontSize: 16,
43+
});
44+
parent.add(textWithEmoji);
45+
46+
expect(textWithEmoji.children).toHaveLength(3);
47+
expect(textWithEmoji.children[0]).toBeInstanceOf(Text);
48+
expect(textWithEmoji.children[1]).toBeInstanceOf(Container);
49+
expect(textWithEmoji.children[2]).toBeInstanceOf(Text);
50+
51+
const newlineContainer = textWithEmoji.children[1] as Container;
52+
expect(newlineContainer.properties.value.width).toBe('100%');
53+
expect(newlineContainer.properties.value.height).toBe(0);
54+
});
55+
56+
it('should handle double newlines correctly', () => {
57+
const parent = new Container();
58+
const textWithEmoji = new TextWithEmoji({
59+
text: 'Hello\n\nWorld',
60+
fontSize: 16,
61+
});
62+
parent.add(textWithEmoji);
63+
64+
expect(textWithEmoji.children).toHaveLength(4);
65+
expect(textWithEmoji.children[0]).toBeInstanceOf(Text);
66+
expect(textWithEmoji.children[1]).toBeInstanceOf(Container);
67+
expect(textWithEmoji.children[2]).toBeInstanceOf(Container);
68+
expect(textWithEmoji.children[3]).toBeInstanceOf(Text);
69+
70+
const newline1 = textWithEmoji.children[1] as Container;
71+
const newline2 = textWithEmoji.children[2] as Container;
72+
73+
expect(newline1.properties.value.width).toBe('100%');
74+
expect(newline1.properties.value.height).toBe(0);
75+
76+
expect(newline2.properties.value.width).toBe('100%');
77+
expect(newline2.properties.value.height).toBe(16); // matches fontSize 16
78+
});
79+
80+
it('should handle leading newline correctly', () => {
81+
const parent = new Container();
82+
const textWithEmoji = new TextWithEmoji({
83+
text: '\nHello',
84+
fontSize: 16,
85+
});
86+
parent.add(textWithEmoji);
87+
88+
expect(textWithEmoji.children).toHaveLength(2);
89+
expect(textWithEmoji.children[0]).toBeInstanceOf(Container);
90+
expect(textWithEmoji.children[1]).toBeInstanceOf(Text);
91+
92+
const newline = textWithEmoji.children[0] as Container;
93+
expect(newline.properties.value.width).toBe('100%');
94+
expect(newline.properties.value.height).toBe(16); // matches fontSize 16
95+
});
96+
});

src/addons/uiblocks/src/core/primitives/TextWithEmoji.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export type TextWithEmojiProperties = InProperties<TextWithEmojiOutProperties>;
2424
// It matches all emoji presentation sequences (including warning signs, hearts, and sparkles)
2525
// and groups Variation Selectors (\uFE0F), ZWJ Joiners (\u200D), and modifiers with their parent emoji.
2626
const WORD_EMOJI_REGEX =
27-
/(?:\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;
27+
/(?:\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;
2828

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

123123
// Reactively rebuild children when the text or sizing properties change
124124
this.cleanupEffect = effect(() => {
125-
const currentText = this.properties.value.text ?? '';
125+
const currentText = (this.properties.value.text ?? '').replace(/\r\n/g, '\n');
126126
const currentFontSize = this.properties.value.fontSize ?? 16;
127127
const emojiCdn = (this.properties.value.emojiCdn ?? 'twemoji') as
128128
| 'twemoji'
@@ -136,11 +136,20 @@ export class TextWithEmoji extends Container<TextWithEmojiOutProperties> {
136136
// Parse text into active structural segment tokens
137137
const segments = currentText.match(WORD_EMOJI_REGEX) || [];
138138
const activeSegments: Array<{
139-
type: 'space' | 'emoji' | 'word';
139+
type: 'space' | 'newline' | 'emoji' | 'word';
140140
text: string;
141+
isConsecutiveNewline?: boolean;
141142
}> = [];
142-
for (const segment of segments) {
143-
if (/^\s+$/.test(segment)) {
143+
for (let i = 0; i < segments.length; i++) {
144+
const segment = segments[i];
145+
if (segment === '\n') {
146+
const isConsecutiveNewline = i === 0 || segments[i - 1] === '\n';
147+
activeSegments.push({
148+
type: 'newline',
149+
text: segment,
150+
isConsecutiveNewline,
151+
});
152+
} else if (/^[ \t\r]+$/.test(segment)) {
144153
activeSegments.push({type: 'space', text: segment});
145154
} else if (
146155
/(?:\p{Emoji_Presentation}|\p{Emoji}\uFE0F)/u.test(segment)
@@ -161,7 +170,7 @@ export class TextWithEmoji extends Container<TextWithEmojiOutProperties> {
161170
const child = this.children[i];
162171
const seg = activeSegments[i];
163172
if (
164-
seg.type === 'space' &&
173+
(seg.type === 'space' || seg.type === 'newline') &&
165174
!(
166175
child instanceof Container &&
167176
!(child instanceof Image) &&
@@ -193,6 +202,12 @@ export class TextWithEmoji extends Container<TextWithEmojiOutProperties> {
193202
width: currentFontSize * 0.26 * seg.text.length,
194203
height: currentFontSize,
195204
});
205+
} else if (seg.type === 'newline') {
206+
const newlineContainer = child as Container;
207+
newlineContainer.setProperties({
208+
width: '100%',
209+
height: seg.isConsecutiveNewline ? currentFontSize : 0,
210+
});
196211
} else if (seg.type === 'emoji') {
197212
const img = child as Image;
198213
img.setProperties({
@@ -240,6 +255,12 @@ export class TextWithEmoji extends Container<TextWithEmojiOutProperties> {
240255
height: currentFontSize,
241256
});
242257
this.add(spaceContainer);
258+
} else if (seg.type === 'newline') {
259+
const newlineContainer = new Container({
260+
width: '100%',
261+
height: seg.isConsecutiveNewline ? currentFontSize : 0,
262+
});
263+
this.add(newlineContainer);
243264
} else if (seg.type === 'emoji') {
244265
const img = new Image({
245266
src: getEmojiUrl(seg.text, emojiCdn),

0 commit comments

Comments
 (0)