Skip to content

Commit a982a6c

Browse files
authored
Merge branch '@ksienkiewicz/docs-fundamentals' into @kacperzolkiewski/docs-api-reference
2 parents d16e03c + 7f9dcb4 commit a982a6c

50 files changed

Lines changed: 2285 additions & 1464 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
appId: swmansion.enriched.example
2+
---
3+
# Fix: merge inline styles with plain text
4+
- launchApp
5+
6+
- tapOn:
7+
id: 'toggle-screen-button'
8+
9+
- tapOn:
10+
id: 'editor-input'
11+
12+
- tapOn:
13+
id: 'toolbar-bold'
14+
- tapOn:
15+
id: 'toolbar-italic'
16+
- tapOn:
17+
id: 'toolbar-underline'
18+
19+
- inputText: 'First line'
20+
- pressKey: Enter
21+
22+
- tapOn:
23+
id: 'toolbar-bold'
24+
- tapOn:
25+
id: 'toolbar-italic'
26+
- tapOn:
27+
id: 'toolbar-underline'
28+
- inputText: 'Second line'
29+
30+
- tapOn:
31+
id: 'editor-input'
32+
point: '5%,75%'
33+
34+
- pressKey: Backspace
35+
36+
- runFlow:
37+
file: '../subflows/capture_or_assert_screenshot.yaml'
38+
env:
39+
SCREENSHOT_NAME: 'inline_styles_merge'
6.9 KB
Loading
8.9 KB
Loading

.playwright/helpers/visual-regression.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const visualRegressionSelectors = {
77
setValueButton: '[data-testid="visual-regression-set-value-button"]',
88
editorHtmlOutput: '[data-testid="visual-regression-editor-html-output"]',
99
htmlStyleOverride: '[data-testid="visual-regression-html-style-override"]',
10+
textShortcutsOverride: '[data-testid="visual-regression-text-shortcuts"]',
1011
} as const;
1112

1213
export function editorLocator(page: Page): Locator {
@@ -52,3 +53,10 @@ export async function setHtmlStyleOverride(
5253
): Promise<void> {
5354
await page.fill(visualRegressionSelectors.htmlStyleOverride, json);
5455
}
56+
57+
export async function setTextShortcutsOverride(
58+
page: Page,
59+
json: string
60+
): Promise<void> {
61+
await page.fill(visualRegressionSelectors.textShortcutsOverride, json);
62+
}

.playwright/tests/links.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,7 @@ test.describe('test-links copy-paste', () => {
479479

480480
await setTestLinksEditorHtml(
481481
page,
482-
'<html><p><a href="custom://link">custom://link</a></p></html>'
482+
'<html><p><a href="/custom-link">/custom-link</a></p></html>'
483483
);
484484

485485
await copyWholeContent(editor);
@@ -488,7 +488,7 @@ test.describe('test-links copy-paste', () => {
488488

489489
await expect
490490
.poll(async () => getTestLinksSerializedHtml(page))
491-
.toContain('<a href="custom://link">custom://link</a>');
491+
.toContain('<a href="/custom-link">/custom-link</a>');
492492
});
493493
});
494494

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import { test, expect, type Page } from '@playwright/test';
2+
3+
import {
4+
focusEnrichedEditable,
5+
getSerializedHtml,
6+
gotoVisualRegression,
7+
setEditorHtml,
8+
setTextShortcutsOverride,
9+
} from '../helpers/visual-regression';
10+
import { toolbarButton } from '../helpers/toolbar';
11+
12+
const DELAY_MS = 80;
13+
14+
async function typeText(page: Page, text: string): Promise<void> {
15+
const editor = await focusEnrichedEditable(page);
16+
await editor.pressSequentially(text, { delay: DELAY_MS });
17+
}
18+
19+
test.describe('text shortcuts — paragraph (default)', () => {
20+
test.beforeEach(async ({ page }) => {
21+
await gotoVisualRegression(page);
22+
});
23+
24+
test('typing "- " at paragraph start creates an unordered list', async ({
25+
page,
26+
}) => {
27+
await setEditorHtml(page, '<html><p></p></html>');
28+
await typeText(page, '- ');
29+
30+
await expect
31+
.poll(async () => {
32+
const html = await getSerializedHtml(page);
33+
return /<ul/i.test(html) && /<li/i.test(html);
34+
})
35+
.toBe(true);
36+
});
37+
38+
test('typing "1. " at paragraph start creates an ordered list', async ({
39+
page,
40+
}) => {
41+
await setEditorHtml(page, '<html><p></p></html>');
42+
await typeText(page, '1. ');
43+
44+
await expect
45+
.poll(async () => {
46+
const html = await getSerializedHtml(page);
47+
return /<ol/i.test(html) && /<li/i.test(html);
48+
})
49+
.toBe(true);
50+
});
51+
52+
test('typing "- " in the middle of text does not trigger list shortcut', async ({
53+
page,
54+
}) => {
55+
await setEditorHtml(page, '<html><p></p></html>');
56+
// Type some text first so "- " is not at the paragraph start
57+
await typeText(page, 'hello - ');
58+
59+
await expect.poll(async () => getSerializedHtml(page)).toMatch(/hello - /);
60+
61+
const html = await getSerializedHtml(page);
62+
expect(html).not.toMatch(/<ul/i);
63+
});
64+
65+
test('typing "- " inside an existing list does not create a new list', async ({
66+
page,
67+
}) => {
68+
await setEditorHtml(page, '<html><ul><li>existing item</li></ul></html>');
69+
const editor = await focusEnrichedEditable(page);
70+
await editor.press('End');
71+
await editor.pressSequentially('1. ', { delay: DELAY_MS });
72+
73+
const html = await getSerializedHtml(page);
74+
expect(html).not.toMatch(/<ol/i);
75+
});
76+
});
77+
78+
test.describe('text shortcuts — paragraph (custom)', () => {
79+
test.beforeEach(async ({ page }) => {
80+
await gotoVisualRegression(page);
81+
});
82+
83+
test('custom "# " shortcut converts to h1', async ({ page }) => {
84+
await setTextShortcutsOverride(page, '[{"trigger":"# ","style":"h1"}]');
85+
await setEditorHtml(page, '<html><p></p></html>');
86+
await typeText(page, '# ');
87+
88+
await expect.poll(async () => getSerializedHtml(page)).toMatch(/<h1/i);
89+
});
90+
91+
test('custom "> " shortcut converts to blockquote', async ({ page }) => {
92+
await setTextShortcutsOverride(
93+
page,
94+
'[{"trigger":"> ","style":"blockquote"}]'
95+
);
96+
await setEditorHtml(page, '<html><p></p></html>');
97+
await typeText(page, '> ');
98+
99+
await expect
100+
.poll(async () => getSerializedHtml(page))
101+
.toMatch(/<blockquote/i);
102+
});
103+
104+
test('custom paragraph shortcut trigger text is removed from the output', async ({
105+
page,
106+
}) => {
107+
await setTextShortcutsOverride(page, '[{"trigger":"# ","style":"h1"}]');
108+
await setEditorHtml(page, '<html><p></p></html>');
109+
await typeText(page, '# ');
110+
111+
const html = await getSerializedHtml(page);
112+
expect(html).not.toMatch(/# /);
113+
});
114+
115+
test('empty textShortcuts array disables all shortcuts', async ({ page }) => {
116+
await setTextShortcutsOverride(page, '[]');
117+
await setEditorHtml(page, '<html><p></p></html>');
118+
await typeText(page, '- ');
119+
120+
const html = await getSerializedHtml(page);
121+
expect(html).not.toMatch(/<ul/i);
122+
expect(html).toMatch(/- /);
123+
});
124+
});
125+
126+
test.describe('text shortcuts — inline (custom)', () => {
127+
test.beforeEach(async ({ page }) => {
128+
await gotoVisualRegression(page);
129+
});
130+
131+
test('single-char delimiter: "*hello*" applies italic', async ({ page }) => {
132+
await setTextShortcutsOverride(page, '[{"trigger":"*","style":"italic"}]');
133+
await setEditorHtml(page, '<html><p></p></html>');
134+
await typeText(page, '*hello*');
135+
136+
await expect
137+
.poll(async () => getSerializedHtml(page))
138+
.toMatch(/<i>hello<\/i>/i);
139+
});
140+
141+
test('double-char delimiter: "**hello**" applies bold', async ({ page }) => {
142+
await setTextShortcutsOverride(page, '[{"trigger":"**","style":"bold"}]');
143+
await setEditorHtml(page, '<html><p></p></html>');
144+
await typeText(page, '**hello**');
145+
146+
await expect
147+
.poll(async () => getSerializedHtml(page))
148+
.toMatch(/<b>hello<\/b>/i);
149+
});
150+
151+
test('backtick delimiter: "`hello`" applies inline code', async ({
152+
page,
153+
}) => {
154+
await setTextShortcutsOverride(
155+
page,
156+
'[{"trigger":"`","style":"inline_code"}]'
157+
);
158+
await setEditorHtml(page, '<html><p></p></html>');
159+
await typeText(page, '`hello`');
160+
161+
await expect
162+
.poll(async () => getSerializedHtml(page))
163+
.toMatch(/<code>hello<\/code>/i);
164+
});
165+
166+
test('inline shortcut removes both delimiters from output', async ({
167+
page,
168+
}) => {
169+
await setTextShortcutsOverride(page, '[{"trigger":"*","style":"italic"}]');
170+
await setEditorHtml(page, '<html><p></p></html>');
171+
await typeText(page, '*hello*');
172+
173+
const html = await getSerializedHtml(page);
174+
expect(html).not.toMatch(/\*/);
175+
});
176+
177+
test('longer trigger takes precedence: "**" does not trigger "*" shortcut', async ({
178+
page,
179+
}) => {
180+
await setTextShortcutsOverride(
181+
page,
182+
'[{"trigger":"*","style":"italic"},{"trigger":"**","style":"bold"}]'
183+
);
184+
await setEditorHtml(page, '<html><p></p></html>');
185+
await typeText(page, '**hello**');
186+
187+
const html = await getSerializedHtml(page);
188+
// Should apply bold, NOT italic
189+
expect(html).toMatch(/<b>hello<\/b>/i);
190+
expect(html).not.toMatch(/<i>/i);
191+
});
192+
193+
test('inline shortcut does not fire when there is no matching opening delimiter', async ({
194+
page,
195+
}) => {
196+
await setTextShortcutsOverride(page, '[{"trigger":"*","style":"italic"}]');
197+
await setEditorHtml(page, '<html><p></p></html>');
198+
await typeText(page, 'hello*');
199+
200+
const html = await getSerializedHtml(page);
201+
expect(html).not.toMatch(/<i>/i);
202+
expect(html).toMatch(/hello\*/);
203+
});
204+
});
205+
206+
test.describe('text shortcuts — mark clearing after inline shortcut', () => {
207+
test.beforeEach(async ({ page }) => {
208+
await gotoVisualRegression(page);
209+
});
210+
211+
test('text typed immediately after bold shortcut is not bold', async ({
212+
page,
213+
}) => {
214+
await setTextShortcutsOverride(page, '[{"trigger":"**","style":"bold"}]');
215+
await setEditorHtml(page, '<html><p></p></html>');
216+
217+
// Apply bold via shortcut
218+
await typeText(page, '**hello**');
219+
220+
// Type more text right after — it should NOT be bold
221+
const editor = await focusEnrichedEditable(page);
222+
await editor.pressSequentially(' world', { delay: DELAY_MS });
223+
224+
await expect(toolbarButton(page, 'bold')).not.toHaveClass(
225+
/toolbar-btn--active/
226+
);
227+
228+
const html = await getSerializedHtml(page);
229+
// " world" must be outside the <b> tag
230+
expect(html).not.toMatch(/<b>hello world<\/b>/i);
231+
expect(html).toMatch(/<b>hello<\/b>/i);
232+
});
233+
234+
test('text typed immediately after italic shortcut is not italic', async ({
235+
page,
236+
}) => {
237+
await setTextShortcutsOverride(page, '[{"trigger":"*","style":"italic"}]');
238+
await setEditorHtml(page, '<html><p></p></html>');
239+
240+
await typeText(page, '*hello*');
241+
242+
const editor = await focusEnrichedEditable(page);
243+
await editor.pressSequentially(' world', { delay: DELAY_MS });
244+
245+
await expect(toolbarButton(page, 'italic')).not.toHaveClass(
246+
/toolbar-btn--active/
247+
);
248+
249+
const html = await getSerializedHtml(page);
250+
expect(html).not.toMatch(/<i>hello world<\/i>/i);
251+
expect(html).toMatch(/<i>hello<\/i>/i);
252+
});
253+
});

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ We can help you build your next dream product –
4747
## Prerequisites
4848

4949
- `react-native-enriched-html` currently supports Android and iOS, Web implementation is still experimental.
50-
- It works only with [the React Native New Architecture (Fabric)](https://reactnative.dev/architecture/landing-page) and supports following React Native releases: `0.81`, `0.82`, `0.83`, `0.84` and `0.85`.
50+
- It works only with [the React Native New Architecture (Fabric)](https://reactnative.dev/architecture/landing-page) and supports following React Native releases: `0.81`, `0.82`, `0.83`, `0.84`, `0.85` and `0.86`.
5151

5252
## Installation
5353

android/src/main/java/com/swmansion/enriched/text/MeasurementStore.kt

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight
1717
import com.facebook.yoga.YogaMeasureMode
1818
import com.facebook.yoga.YogaMeasureOutput
1919
import com.swmansion.enriched.common.EnrichedConstants
20+
import com.swmansion.enriched.common.GumboNormalizer
2021
import com.swmansion.enriched.common.allowFontScalingFromProps
2122
import com.swmansion.enriched.common.parser.EnrichedParser
2223
import com.swmansion.enriched.common.pixelFromSpOrDp
@@ -104,24 +105,35 @@ object MeasurementStore {
104105
props: ReadableMap?,
105106
): CharSequence {
106107
val text = props?.getString("text") ?: ""
108+
val isInternalHtml = text.startsWith("<html>") && text.endsWith("</html>")
109+
val useHtmlNormalizer = useHtmlNormalizerFromProps(props)
107110

108-
val isHtml = text.startsWith("<html>") && text.endsWith("</html>")
109-
if (!isHtml) return text
111+
if (!isInternalHtml && !useHtmlNormalizer) {
112+
return text
113+
}
110114

111115
try {
116+
val textToParse = if (isInternalHtml) text else GumboNormalizer.normalizeHtml(text)
112117
val style = props?.getMap("htmlStyle") ?: return text
113118
val allowFontScaling = allowFontScalingFromProps(props)
114-
val enrichedStyle =
115-
EnrichedTextStyle.fromReadableMap(context as ReactContext, fontSize, style, allowFontScaling)
119+
val enrichedStyle = EnrichedTextStyle.fromReadableMap(context as ReactContext, fontSize, style, allowFontScaling)
120+
116121
val factory = EnrichedTextSpanFactory()
117-
val parsed = EnrichedParser.fromHtml(text, enrichedStyle, factory)
122+
val parsed = EnrichedParser.fromHtml(textToParse, enrichedStyle, factory)
118123
return parsed.trimEnd('\n')
119124
} catch (e: Exception) {
120125
Log.w("MeasurementStore", "Error parsing initial HTML text: ${e.message}")
121126
return text
122127
}
123128
}
124129

130+
private fun useHtmlNormalizerFromProps(props: ReadableMap?): Boolean {
131+
if (props == null || !props.hasKey("useHtmlNormalizer") || props.isNull("useHtmlNormalizer")) {
132+
return false
133+
}
134+
return props.getBoolean("useHtmlNormalizer")
135+
}
136+
125137
private fun getInitialFontSize(props: ReadableMap?): Float {
126138
val propsFontSize = props?.getDouble("fontSize")?.toFloat() ?: EnrichedConstants.TEXT_DEFAULT_FONT_SIZE
127139
val fontSize =

android/src/main/java/com/swmansion/enriched/textinput/utils/ShortcutsHandler.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ class ShortcutsHandler(
4646
val resolvedStyle = resolveStyleName(styleName) ?: continue
4747

4848
s.replace(effectiveTriggerStart, effectiveTriggerStart + trigger.length, "")
49-
view.toggleStyle(resolvedStyle)
49+
view.verifyAndToggleStyle(resolvedStyle)
5050
return
5151
}
5252
}

0 commit comments

Comments
 (0)