From 9af8a24b788868727ab4716570d2d523d4056b7c Mon Sep 17 00:00:00 2001 From: David Li Date: Tue, 2 Jun 2026 10:47:43 -0700 Subject: [PATCH] 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. --- .../src/core/primitives/TextWithEmoji.test.ts | 96 +++++++++++++++++++ .../src/core/primitives/TextWithEmoji.ts | 36 +++++-- 2 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 src/addons/uiblocks/src/core/primitives/TextWithEmoji.test.ts diff --git a/src/addons/uiblocks/src/core/primitives/TextWithEmoji.test.ts b/src/addons/uiblocks/src/core/primitives/TextWithEmoji.test.ts new file mode 100644 index 00000000..67f818dd --- /dev/null +++ b/src/addons/uiblocks/src/core/primitives/TextWithEmoji.test.ts @@ -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 + }); +}); diff --git a/src/addons/uiblocks/src/core/primitives/TextWithEmoji.ts b/src/addons/uiblocks/src/core/primitives/TextWithEmoji.ts index 40994b9e..a1af8969 100644 --- a/src/addons/uiblocks/src/core/primitives/TextWithEmoji.ts +++ b/src/addons/uiblocks/src/core/primitives/TextWithEmoji.ts @@ -24,7 +24,7 @@ export type TextWithEmojiProperties = InProperties; // 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) @@ -122,7 +122,10 @@ export class TextWithEmoji extends Container { // 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' @@ -136,11 +139,20 @@ export class TextWithEmoji extends Container { // 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) @@ -161,7 +173,7 @@ export class TextWithEmoji extends Container { 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) && @@ -193,6 +205,12 @@ export class TextWithEmoji extends Container { 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({ @@ -240,6 +258,12 @@ export class TextWithEmoji extends Container { 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),