Skip to content

Commit 8627c87

Browse files
committed
fix: paragraphs line breaks
1 parent 2393e71 commit 8627c87

File tree

3 files changed

+101
-46
lines changed

3 files changed

+101
-46
lines changed

packages/nstreamdown/angular/components/md-paragraph.ts

Lines changed: 74 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,19 @@
11
/**
22
* MdParagraph Component
3-
* Renders a paragraph of text with inline formatting
3+
* Renders a paragraph of text with inline formatting using FormattedString
4+
* for proper text flow and wrapping
45
*/
5-
import { Component, NO_ERRORS_SCHEMA, ChangeDetectionStrategy, computed, input } from '@angular/core';
6+
import { Component, NO_ERRORS_SCHEMA, ChangeDetectionStrategy, computed, input, signal, effect } from '@angular/core';
67
import { NativeScriptCommonModule } from '@nativescript/angular';
8+
import { FormattedString, Span, Label, Color } from '@nativescript/core';
79
import { MarkdownToken, parseInlineFormatting } from '@nstudio/nstreamdown';
810
import { openUrl } from '@nstudio/nstreamdown';
911

1012
@Component({
1113
selector: 'MdParagraph',
1214
template: `
1315
<StackLayout class="mb-3">
14-
<FlexboxLayout flexWrap="wrap" alignItems="center">
15-
@for (token of displayTokens(); track $index) {
16-
@switch (token.type) {
17-
@case ('text') {
18-
<Label [text]="token.content" class="text-sm text-slate-700 dark:text-slate-300 leading-[3]" textWrap="true"></Label>
19-
}
20-
@case ('bold') {
21-
<Label [text]="token.content" class="text-sm text-slate-800 dark:text-slate-100 font-bold leading-[3]" textWrap="true"></Label>
22-
}
23-
@case ('italic') {
24-
<Label [text]="token.content" class="text-sm text-slate-700 dark:text-slate-300 italic leading-[3]" textWrap="true"></Label>
25-
}
26-
@case ('bold-italic') {
27-
<Label [text]="token.content" class="text-sm text-slate-800 dark:text-slate-100 font-bold italic leading-[3]" textWrap="true"></Label>
28-
}
29-
@case ('strikethrough') {
30-
<Label [text]="token.content" class="text-sm text-slate-400 dark:text-slate-500 leading-[3]" textDecoration="line-through" textWrap="true"></Label>
31-
}
32-
@case ('code-inline') {
33-
<Label [text]="token.content" class="text-xs font-mono bg-slate-100 dark:bg-slate-700 text-pink-600 dark:text-pink-400 rounded px-1" textWrap="true"></Label>
34-
}
35-
@case ('link') {
36-
<Label [text]="token.content" class="text-sm text-blue-600 dark:text-blue-400 leading-[3]" textDecoration="underline" (tap)="onLinkTap(token)" textWrap="true"></Label>
37-
}
38-
@case ('math-inline') {
39-
<Label [text]="token.content" class="text-sm font-mono text-purple-700 dark:text-purple-300 bg-purple-50 dark:bg-purple-900 rounded px-1" textWrap="true"></Label>
40-
}
41-
}
42-
}
43-
</FlexboxLayout>
16+
<Label [formattedText]="formattedString()" textWrap="true" class="text-sm text-slate-700 dark:text-slate-300 leading-[3]" (tap)="onTap($event)"></Label>
4417
</StackLayout>
4518
`,
4619
imports: [NativeScriptCommonModule],
@@ -51,6 +24,9 @@ export class MdParagraph {
5124
content = input('');
5225
children = input<MarkdownToken[]>([]);
5326

27+
// Store link metadata for tap handling
28+
private linkUrls: Map<number, string> = new Map();
29+
5430
displayTokens = computed(() => {
5531
const kids = this.children();
5632
const txt = this.content();
@@ -62,6 +38,72 @@ export class MdParagraph {
6238
return [];
6339
});
6440

41+
formattedString = computed(() => {
42+
const tokens = this.displayTokens();
43+
const fs = new FormattedString();
44+
this.linkUrls.clear();
45+
46+
tokens.forEach((token, index) => {
47+
const span = new Span();
48+
span.text = token.content;
49+
50+
switch (token.type) {
51+
case 'bold':
52+
span.fontWeight = 'bold';
53+
break;
54+
case 'italic':
55+
span.fontStyle = 'italic';
56+
break;
57+
case 'bold-italic':
58+
span.fontWeight = 'bold';
59+
span.fontStyle = 'italic';
60+
break;
61+
case 'strikethrough':
62+
span.textDecoration = 'line-through';
63+
span.color = new Color('#94a3b8'); // slate-400
64+
break;
65+
case 'code-inline':
66+
span.fontFamily = 'monospace';
67+
span.backgroundColor = new Color('#f1f5f9'); // slate-100
68+
span.color = new Color('#db2777'); // pink-600
69+
break;
70+
case 'link':
71+
span.color = new Color('#2563eb'); // blue-600
72+
span.textDecoration = 'underline';
73+
// Store the URL for this span index
74+
const url = token.metadata?.['url'] as string;
75+
if (url && url !== 'streamdown:incomplete-link') {
76+
this.linkUrls.set(index, url);
77+
}
78+
break;
79+
case 'math-inline':
80+
span.fontFamily = 'monospace';
81+
span.color = new Color('#7c3aed'); // purple-600
82+
break;
83+
default:
84+
// text - use default styling
85+
break;
86+
}
87+
88+
fs.spans.push(span);
89+
});
90+
91+
return fs;
92+
});
93+
94+
onTap(args: any) {
95+
// Handle link taps by checking which span was tapped
96+
// For now, if there's only one link, open it
97+
if (this.linkUrls.size === 1) {
98+
const url = this.linkUrls.values().next().value;
99+
if (url) {
100+
openUrl(url);
101+
}
102+
}
103+
// TODO: For multiple links, we'd need to determine which span was tapped
104+
// This would require native touch handling to get the tapped character position
105+
}
106+
65107
onLinkTap(token: MarkdownToken) {
66108
const url = token.metadata?.['url'] as string;
67109
if (url && url !== 'streamdown:incomplete-link') {

packages/nstreamdown/lib/markdown-parser.spec.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -88,14 +88,15 @@ describe('parseInlineFormatting', () => {
8888
});
8989

9090
describe('punctuation merging after links', () => {
91-
it('should merge comma with preceding link', () => {
91+
it('should merge comma and space with preceding link', () => {
9292
const tokens = parseInlineFormatting('Visit [streamdown.ai](https://streamdown.ai), designed for streaming');
9393
expect(tokens).toHaveLength(3);
9494
expect(tokens[0]).toEqual({ type: 'text', raw: 'Visit ', content: 'Visit ' });
9595
expect(tokens[1].type).toBe('link');
96-
expect(tokens[1].content).toBe('streamdown.ai,');
96+
// Comma and space are merged with link to prevent line break issues
97+
expect(tokens[1].content).toBe('streamdown.ai, ');
9798
expect(tokens[1].metadata?.url).toBe('https://streamdown.ai');
98-
expect(tokens[2]).toEqual({ type: 'text', raw: ' designed for streaming', content: ' designed for streaming' });
99+
expect(tokens[2]).toEqual({ type: 'text', raw: 'designed for streaming', content: 'designed for streaming' });
99100
});
100101

101102
it('should merge period with preceding link', () => {
@@ -114,19 +115,29 @@ describe('parseInlineFormatting', () => {
114115
expect(tokens[1].content).toBe('link!?');
115116
});
116117

117-
it('should merge punctuation with preceding bold text', () => {
118+
it('should merge punctuation and space with preceding bold text', () => {
118119
const tokens = parseInlineFormatting('This is **important**, please note');
119120
expect(tokens).toHaveLength(3);
120121
expect(tokens[0]).toEqual({ type: 'text', raw: 'This is ', content: 'This is ' });
121122
expect(tokens[1].type).toBe('bold');
122-
expect(tokens[1].content).toBe('important,');
123-
expect(tokens[2]).toEqual({ type: 'text', raw: ' please note', content: ' please note' });
123+
// Comma and space are merged with bold to prevent line break issues
124+
expect(tokens[1].content).toBe('important, ');
125+
expect(tokens[2]).toEqual({ type: 'text', raw: 'please note', content: 'please note' });
124126
});
125127

126-
it('should not merge punctuation if followed by more text without space', () => {
128+
it('should not merge space if no punctuation before it', () => {
129+
const tokens = parseInlineFormatting('Visit [site](https://site.com) and more');
130+
// No punctuation, so space stays with following text
131+
expect(tokens).toHaveLength(3);
132+
expect(tokens[0]).toEqual({ type: 'text', raw: 'Visit ', content: 'Visit ' });
133+
expect(tokens[1].type).toBe('link');
134+
expect(tokens[1].content).toBe('site');
135+
expect(tokens[2]).toEqual({ type: 'text', raw: ' and more', content: ' and more' });
136+
});
137+
138+
it('should merge punctuation but keep remaining text without leading space', () => {
127139
const tokens = parseInlineFormatting('Visit [site](https://site.com),no space');
128-
// "Visit " is text, then link, then ",no space" is text
129-
// Punctuation merging will merge the comma with the link
140+
// "Visit " is text, then link with comma merged, then "no space" is text
130141
expect(tokens).toHaveLength(3);
131142
expect(tokens[0]).toEqual({ type: 'text', raw: 'Visit ', content: 'Visit ' });
132143
expect(tokens[1].type).toBe('link');
@@ -210,7 +221,8 @@ describe('parseMarkdown', () => {
210221
expect(result.tokens[0].type).toBe('paragraph');
211222
const linkChild = result.tokens[0].children?.find((c) => c.type === 'link');
212223
expect(linkChild).toBeDefined();
213-
expect(linkChild?.content).toBe('site,');
224+
// Comma and space are merged with link to prevent line break issues
225+
expect(linkChild?.content).toBe('site, ');
214226
});
215227
});
216228

packages/nstreamdown/lib/markdown-parser.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -357,20 +357,21 @@ export function parseInlineFormatting(text: string): MarkdownToken[] {
357357
});
358358
}
359359

360-
// Post-process: merge leading punctuation from text tokens into preceding tokens
360+
// Post-process: merge leading punctuation (and following space) from text tokens into preceding tokens
361361
// This prevents punctuation like commas from wrapping to a new line after links
362-
const LEADING_PUNCTUATION = /^([.,;:!?'")\]}>]+)/;
362+
// Also merge the space after punctuation to keep text flowing naturally
363+
const LEADING_PUNCTUATION_AND_SPACE = /^([.,;:!?'")\]}>]+\s*)/;
363364
const mergedTokens: MarkdownToken[] = [];
364365

365366
for (let i = 0; i < tokens.length; i++) {
366367
const token = tokens[i];
367368

368369
if (token.type === 'text' && i > 0) {
369370
const prevToken = mergedTokens[mergedTokens.length - 1];
370-
const punctMatch = token.content.match(LEADING_PUNCTUATION);
371+
const punctMatch = token.content.match(LEADING_PUNCTUATION_AND_SPACE);
371372

372373
if (punctMatch && prevToken && prevToken.type !== 'text') {
373-
// Merge the punctuation with the previous token's content
374+
// Merge the punctuation (and trailing space) with the previous token's content
374375
const punctuation = punctMatch[1];
375376
prevToken.content = prevToken.content + punctuation;
376377
prevToken.raw = prevToken.raw + punctuation;

0 commit comments

Comments
 (0)