diff --git a/.maestro/enrichedInput/flows/custom_style_colors_visual.yaml b/.maestro/enrichedInput/flows/custom_style_colors_visual.yaml new file mode 100644 index 000000000..3b4d63c8d --- /dev/null +++ b/.maestro/enrichedInput/flows/custom_style_colors_visual.yaml @@ -0,0 +1,129 @@ +appId: swmansion.enriched.example +--- +# Validates custom colors on plain text, inline styles, and paragraph styles. +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +- tapOn: + id: 'editor-input' + +# Section 1: Plain text with colors + +- tapOn: + id: 'toolbar-text-color' +- tapOn: + id: 'color-swatch-FF0000' +- inputText: 'Red text' +- tapOn: + id: 'toolbar-text-color' +- tapOn: + id: 'color-swatch-clear' +- inputText: ' ' + +- tapOn: + id: 'toolbar-bg-color' +- tapOn: + id: 'color-swatch-FFFF00' +- inputText: 'Yellow back' +- tapOn: + id: 'toolbar-bg-color' +- tapOn: + id: 'color-swatch-clear' +- inputText: ' ' + +- tapOn: + id: 'toolbar-text-color' +- tapOn: + id: 'color-swatch-FF0000' +- tapOn: + id: 'toolbar-bg-color' +- tapOn: + id: 'color-swatch-FFFF00' +- inputText: 'Red+Yellow' +- tapOn: + id: 'toolbar-text-color' +- tapOn: + id: 'color-swatch-clear' +- tapOn: + id: 'toolbar-bg-color' +- tapOn: + id: 'color-swatch-clear' +- inputText: ' ' + +# Section 2: Inline styles + color + +- tapOn: + id: 'toolbar-bold' +- tapOn: + id: 'toolbar-text-color' +- tapOn: + id: 'color-swatch-FF0000' +- inputText: 'Bold red' +- tapOn: + id: 'toolbar-bold' +- tapOn: + id: 'toolbar-text-color' +- tapOn: + id: 'color-swatch-clear' +- inputText: ' ' + +- tapOn: + id: 'toolbar-italic' +- tapOn: + id: 'toolbar-bg-color' +- tapOn: + id: 'color-swatch-FFFF00' +- inputText: 'Italic yellow back' +- tapOn: + id: 'toolbar-italic' +- tapOn: + id: 'toolbar-bg-color' +- tapOn: + id: 'color-swatch-clear' +- inputText: ' ' +- pressKey: Enter + +# Section 3: Paragraph styles + color + +- tapOn: + id: 'toolbar-heading-5' +- tapOn: + id: 'toolbar-text-color' +- tapOn: + id: 'color-swatch-FF0000' +- inputText: 'H5 red' +- pressKey: Enter +- tapOn: + id: 'toolbar-text-color' +- scroll +- tapOn: + id: 'color-swatch-clear' +- scrollUntilVisible: + element: + id: 'blur-button' + direction: UP + +- tapOn: + id: 'toolbar-quote' +- tapOn: + id: 'toolbar-bg-color' +- scroll +- tapOn: + id: 'color-swatch-FFFF00' +- inputText: 'Quote yellow back' +- tapOn: + id: 'toolbar-bg-color' +- scroll +- tapOn: + id: 'color-swatch-clear' +- scrollUntilVisible: + element: + id: 'blur-button' + direction: UP + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'custom_style_colors' diff --git a/.maestro/enrichedInput/flows/custom_style_fonts_visual.yaml b/.maestro/enrichedInput/flows/custom_style_fonts_visual.yaml new file mode 100644 index 000000000..aa391cf65 --- /dev/null +++ b/.maestro/enrichedInput/flows/custom_style_fonts_visual.yaml @@ -0,0 +1,131 @@ +appId: swmansion.enriched.example +--- +# Validates custom font size and font family on plain text, inline styles, and paragraph styles. +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +- tapOn: + id: 'editor-input' + +# Section 1: Plain text with custom fonts + +- tapOn: + id: 'toolbar-font-size' +- tapOn: + id: 'font-size-24' +- inputText: '24px plain' +- tapOn: + id: 'toolbar-font-size' +- tapOn: + id: 'font-size-clear' +- inputText: ' ' + +- tapOn: + id: 'toolbar-font-family' +- tapOn: + id: 'font-family-Nunito-Bold' +- inputText: 'Bold family' +- tapOn: + id: 'toolbar-font-family' +- tapOn: + id: 'font-family-clear' +- inputText: ' ' + +- tapOn: + id: 'toolbar-font-size' +- tapOn: + id: 'font-size-32' +- tapOn: + id: 'toolbar-font-family' +- tapOn: + id: 'font-family-Courier' +- inputText: '32 Courier' +- tapOn: + id: 'toolbar-font-size' +- tapOn: + id: 'font-size-clear' +- tapOn: + id: 'toolbar-font-family' +- tapOn: + id: 'font-family-clear' +- inputText: ' ' + +# Section 2: Inline styles + custom fonts + +- tapOn: + id: 'toolbar-bold' +- tapOn: + id: 'toolbar-font-size' +- tapOn: + id: 'font-size-20' +- inputText: 'Bold 20px' +- tapOn: + id: 'toolbar-bold' +- tapOn: + id: 'toolbar-font-size' +- tapOn: + id: 'font-size-clear' +- inputText: ' ' + +- tapOn: + id: 'toolbar-italic' +- tapOn: + id: 'toolbar-font-family' +- tapOn: + id: 'font-family-Nunito-Italic' +- inputText: 'Italic family' +- tapOn: + id: 'toolbar-italic' +- tapOn: + id: 'toolbar-font-family' +- tapOn: + id: 'font-family-clear' +- inputText: ' ' + +- tapOn: + id: 'toolbar-inline-code' +- tapOn: + id: 'toolbar-font-family' +- scroll +- tapOn: + id: 'font-family-CascadiaCode-Bold' +- scrollUntilVisible: + element: + id: 'blur-button' + direction: UP +- inputText: 'Code bold' +- tapOn: + id: 'toolbar-inline-code' +- tapOn: + id: 'toolbar-font-family' +- scroll +- tapOn: + id: 'font-family-clear' +- scrollUntilVisible: + element: + id: 'blur-button' + direction: UP +- inputText: ' ' +- pressKey: Enter + +# Section 3: Paragraph styles + custom fonts + +- tapOn: + id: 'toolbar-quote' +- tapOn: + id: 'toolbar-font-family' +- scroll +- tapOn: + id: 'font-family-CascadiaCode-Regular' +- inputText: 'Quote code family' +- scrollUntilVisible: + element: + id: 'blur-button' + direction: UP + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'custom_style_fonts' diff --git a/.maestro/enrichedInput/screenshots/android/custom_style_colors.png b/.maestro/enrichedInput/screenshots/android/custom_style_colors.png new file mode 100644 index 000000000..09ee4dc82 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/custom_style_colors.png differ diff --git a/.maestro/enrichedInput/screenshots/android/custom_style_fonts.png b/.maestro/enrichedInput/screenshots/android/custom_style_fonts.png new file mode 100644 index 000000000..df3eafffb Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/custom_style_fonts.png differ diff --git a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_1.png b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_1.png index 74947bcfd..8f6adef9b 100644 Binary files a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_1.png and b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_1.png differ diff --git a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_2.png b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_2.png index 7a053e6c5..6bbb64a1e 100644 Binary files a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_2.png and b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_2.png differ diff --git a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_3.png b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_3.png index d80b9a756..eb601c3e2 100644 Binary files a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_3.png and b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_3.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/custom_style_colors.png b/.maestro/enrichedInput/screenshots/ios/custom_style_colors.png new file mode 100644 index 000000000..55f921997 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/custom_style_colors.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/custom_style_fonts.png b/.maestro/enrichedInput/screenshots/ios/custom_style_fonts.png new file mode 100644 index 000000000..26a41abb7 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/custom_style_fonts.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_1.png b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_1.png index 7f0cfabe9..650394951 100644 Binary files a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_1.png and b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_1.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_2.png b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_2.png index a32731ece..a50059814 100644 Binary files a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_2.png and b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_2.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_3.png b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_3.png index b0c1a44ca..dd2d16428 100644 Binary files a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_3.png and b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_3.png differ diff --git a/.maestro/enrichedText/flows/custom_style_colors_visual.yaml b/.maestro/enrichedText/flows/custom_style_colors_visual.yaml new file mode 100644 index 000000000..e304b5b94 --- /dev/null +++ b/.maestro/enrichedText/flows/custom_style_colors_visual.yaml @@ -0,0 +1,28 @@ +appId: swmansion.enriched.example +--- +# Validates that custom style colors are displayed correctly +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +- tapOn: + id: 'toggle-enriched-text-screen-button' + +- runFlow: + file: '../subflows/set_enriched_text_value.yaml' + env: + VALUE: > + +
Standard 6-digit Hex text
+White text on black background
+25% transparent green background
+50% transparent blue text
+Red 3-digit shorthand text
+Regular family
+24px plain
+Bold family
+32 Courier
+Bold 20px Italic family Code bold
+ + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'custom_style_fonts_visual' diff --git a/.maestro/enrichedText/screenshots/android/custom_style_colors_visual.png b/.maestro/enrichedText/screenshots/android/custom_style_colors_visual.png new file mode 100644 index 000000000..37f78b87e Binary files /dev/null and b/.maestro/enrichedText/screenshots/android/custom_style_colors_visual.png differ diff --git a/.maestro/enrichedText/screenshots/android/custom_style_fonts_visual.png b/.maestro/enrichedText/screenshots/android/custom_style_fonts_visual.png new file mode 100644 index 000000000..778ef6313 Binary files /dev/null and b/.maestro/enrichedText/screenshots/android/custom_style_fonts_visual.png differ diff --git a/.maestro/enrichedText/screenshots/ios/custom_style_colors_visual.png b/.maestro/enrichedText/screenshots/ios/custom_style_colors_visual.png new file mode 100644 index 000000000..5e42bea28 Binary files /dev/null and b/.maestro/enrichedText/screenshots/ios/custom_style_colors_visual.png differ diff --git a/.maestro/enrichedText/screenshots/ios/custom_style_fonts_visual.png b/.maestro/enrichedText/screenshots/ios/custom_style_fonts_visual.png new file mode 100644 index 000000000..03f364cc3 Binary files /dev/null and b/.maestro/enrichedText/screenshots/ios/custom_style_fonts_visual.png differ diff --git a/.playwright/screenshots/custom-style-colors-visual.png b/.playwright/screenshots/custom-style-colors-visual.png new file mode 100644 index 000000000..2b55288e7 Binary files /dev/null and b/.playwright/screenshots/custom-style-colors-visual.png differ diff --git a/.playwright/screenshots/custom-style-fonts-visual.png b/.playwright/screenshots/custom-style-fonts-visual.png new file mode 100644 index 000000000..db74038af Binary files /dev/null and b/.playwright/screenshots/custom-style-fonts-visual.png differ diff --git a/.playwright/screenshots/enriched-text-blockquote-code-codeblock.png b/.playwright/screenshots/enriched-text-blockquote-code-codeblock.png index cd7fc5f52..984ed253d 100644 Binary files a/.playwright/screenshots/enriched-text-blockquote-code-codeblock.png and b/.playwright/screenshots/enriched-text-blockquote-code-codeblock.png differ diff --git a/.playwright/screenshots/inline-styles.png b/.playwright/screenshots/inline-styles.png index ff7c2f693..1ceb631d9 100644 Binary files a/.playwright/screenshots/inline-styles.png and b/.playwright/screenshots/inline-styles.png differ diff --git a/.playwright/tests/customStyleColors.spec.ts b/.playwright/tests/customStyleColors.spec.ts new file mode 100644 index 000000000..d0eeb9d21 --- /dev/null +++ b/.playwright/tests/customStyleColors.spec.ts @@ -0,0 +1,308 @@ +import { test, expect, type Page } from '@playwright/test'; + +import { + editorLocator, + getSerializedHtml, + gotoVisualRegression, + setEditorHtml, +} from '../helpers/visual-regression'; +import { toolbarButton } from '../helpers/toolbar'; + +function textColorButton(page: Page) { + return page.locator('[data-testid="toolbar-text-color"]'); +} + +function bgColorButton(page: Page) { + return page.locator('[data-testid="toolbar-bg-color"]'); +} + +function colorSwatch(page: Page, color: string) { + return page.locator( + `[data-testid="toolbar-color-swatch-${color.replace('#', '')}"]` + ); +} + +function colorSwatchClear(page: Page) { + return page.locator('[data-testid="toolbar-color-swatch-clear"]'); +} + +async function applyTextColor(page: Page, color: string) { + await textColorButton(page).click(); + await colorSwatch(page, color).click(); +} + +async function clearTextColor(page: Page) { + await textColorButton(page).click(); + await colorSwatchClear(page).click(); +} + +async function applyBgColor(page: Page, color: string) { + await bgColorButton(page).click(); + await colorSwatch(page, color).click(); +} + +async function clearBgColor(page: Page) { + await bgColorButton(page).click(); + await colorSwatchClear(page).click(); +} + +const ROUND_TRIP_CASES: { name: string; input: string; expected: string }[] = [ + { + name: 'foreground color only', + input: 'Quote code family
Red text
', + expected: + 'Red text
', + }, + { + name: 'background color only', + input: + 'Yellow bg
', + expected: + 'Yellow bg
', + }, + { + name: 'foreground and background color', + input: + 'Both
', + expected: + 'Both
', + }, + { + name: '8-digit hex background (transparent)', + input: + '25% green bg
', + expected: + '25% green bg
', + }, + { + name: '8-digit hex foreground (transparent)', + input: + '50% blue text
', + expected: + '50% blue text
', + }, + { + name: 'color inside heading', + input: + 'Bold red
', + expected: + 'Bold red
', + }, + { + name: 'multiple colored spans in one paragraph', + input: + 'Red plain Blue
', + expected: + 'Red plain Blue
', + }, +]; + +test.describe('custom style colors - HTML serialization', () => { + test.beforeEach(async ({ page }) => { + await gotoVisualRegression(page); + }); + + for (const { name, input, expected } of ROUND_TRIP_CASES) { + test(name, async ({ page }) => { + await setEditorHtml(page, input); + await expect.poll(async () => getSerializedHtml(page)).toBe(expected); + }); + } +}); + +test('custom style colors visual regression', async ({ page }) => { + await gotoVisualRegression(page); + + const html = [ + '', + 'Standard 6-digit hex text
', + 'White text on black background
', + '25% transparent green background
', + '50% transparent blue text
', + 'Red 3-digit shorthand text
', + 'Red text
' + ); + }); + + test('clear foreground color stops coloring new text', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyTextColor(page, '#FF0000'); + await editor.pressSequentially('Red', { delay: 80 }); + await clearTextColor(page); + await editor.pressSequentially(' plain', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + 'Red plain
' + ); + }); + + test('apply background color then type text', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyBgColor(page, '#FFFF00'); + await editor.pressSequentially('Yellow back', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + 'Yellow back
' + ); + }); + + test('clear background color stops coloring new text', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyBgColor(page, '#FFFF00'); + await editor.pressSequentially('Yellow', { delay: 80 }); + await clearBgColor(page); + await editor.pressSequentially(' plain', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + 'Yellow plain
' + ); + }); + + test('apply foreground and background color together', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyTextColor(page, '#FF0000'); + await applyBgColor(page, '#FFFF00'); + await editor.pressSequentially('Red+Yellow', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + 'Red+Yellow
' + ); + }); + + test('foreground color with bold', async ({ page }) => { + const editor = editorLocator(page); + const boldBtn = toolbarButton(page, 'bold'); + await editor.click(); + + await boldBtn.click(); + await applyTextColor(page, '#FF0000'); + await editor.pressSequentially('Bold red', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + 'Bold red
' + ); + }); + + test('background color with italic', async ({ page }) => { + const editor = editorLocator(page); + const italicBtn = toolbarButton(page, 'italic'); + await editor.click(); + + await italicBtn.click(); + await applyBgColor(page, '#FFFF00'); + await editor.pressSequentially('Italic yellow back', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + 'Italic yellow back
' + ); + }); + + test('toolbar text-color button shows active swatch when color is set', async ({ + page, + }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyTextColor(page, '#FF0000'); + + // Re-open the picker – the chosen swatch should be marked as active + await textColorButton(page).click(); + await expect(colorSwatch(page, '#FF0000')).toHaveClass( + /toolbar-color-swatch--active/ + ); + + // Close the picker + await textColorButton(page).click(); + }); + + test('custom style is not active right after clearing it', async ({ + page, + }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyTextColor(page, '#FF0000'); + await editor.pressSequentially('Red', { delay: 80 }); + await clearTextColor(page); + + // The cursor still sits right after the red text, but the color was + // cleared for the next typed characters, so no swatch should be active + await textColorButton(page).click(); + await expect(colorSwatch(page, '#FF0000')).not.toHaveClass( + /toolbar-color-swatch--active/ + ); + + // Close the picker + await textColorButton(page).click(); + }); + + test('toolbar bg-color button shows active swatch when color is set', async ({ + page, + }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyBgColor(page, '#FFFF00'); + + // Re-open the picker – the chosen swatch should be marked as active + await bgColorButton(page).click(); + await expect(colorSwatch(page, '#FFFF00')).toHaveClass( + /toolbar-color-swatch--active/ + ); + + // Close the picker + await bgColorButton(page).click(); + }); +}); diff --git a/.playwright/tests/customStyleFonts.spec.ts b/.playwright/tests/customStyleFonts.spec.ts new file mode 100644 index 000000000..4b4afc701 --- /dev/null +++ b/.playwright/tests/customStyleFonts.spec.ts @@ -0,0 +1,330 @@ +import { test, expect, type Page } from '@playwright/test'; + +import { + editorLocator, + focusEnrichedEditable, + getSerializedHtml, + gotoVisualRegression, + setEditorHtml, +} from '../helpers/visual-regression'; +import { toolbarButton } from '../helpers/toolbar'; + +function fontSizeButton(page: Page) { + return page.locator('[data-testid="toolbar-font-size"]'); +} + +function fontFamilyButton(page: Page) { + return page.locator('[data-testid="toolbar-font-family"]'); +} + +function fontSizeOption(page: Page, size: number) { + return page.locator(`[data-testid="font-size-${size}"]`); +} + +function fontFamilyOption(page: Page, value: string) { + return page.locator(`[data-testid="font-family-${value}"]`); +} + +function fontSizeClear(page: Page) { + return page.locator('[data-testid="font-size-clear"]'); +} + +function fontFamilyClear(page: Page) { + return page.locator('[data-testid="font-family-clear"]'); +} + +async function applyFontSize(page: Page, size: number) { + await fontSizeButton(page).click(); + await fontSizeOption(page, size).click(); +} + +async function clearFontSize(page: Page) { + await fontSizeButton(page).click(); + await fontSizeClear(page).click(); +} + +async function applyFontFamily(page: Page, value: string) { + await fontFamilyButton(page).click(); + await fontFamilyOption(page, value).click(); +} + +async function clearFontFamily(page: Page) { + await fontFamilyButton(page).click(); + await fontFamilyClear(page).click(); +} + +const ROUND_TRIP_CASES: { name: string; input: string; expected: string }[] = [ + { + name: 'font size only', + input: + 'Sized text
', + expected: + 'Sized text
', + }, + { + name: 'font family only', + input: + 'Courier text
', + expected: + 'Courier text
', + }, + { + name: 'font family with a comma-separated fallback is reduced to first family', + input: + 'Courier text
', + expected: + 'Courier text
', + }, + { + name: 'font size and font family together', + input: + 'Both
', + expected: + 'Both
', + }, + { + name: 'font size and color together', + input: + 'Red big
', + expected: + 'Red big
', + }, + { + name: 'font size inside heading', + input: + 'Bold big
', + expected: + 'Bold big
', + }, + { + name: 'multiple sized spans in one paragraph', + input: + 'Small plain Big
', + expected: + 'Small plain Big
', + }, +]; + +test.describe('custom style fonts - HTML serialization', () => { + test.beforeEach(async ({ page }) => { + await gotoVisualRegression(page); + }); + + for (const { name, input, expected } of ROUND_TRIP_CASES) { + test(name, async ({ page }) => { + await setEditorHtml(page, input); + await expect.poll(async () => getSerializedHtml(page)).toBe(expected); + }); + } +}); + +test('custom style fonts visual regression', async ({ page }) => { + await gotoVisualRegression(page); + + const html = [ + '', + 'Regular family
', + '24px plain
', + 'Sans family
', + '32 Courier
', + 'Bold 20px ',
+ 'Italic family ',
+ 'Code mono
', + '', + ].join(''); + + await setEditorHtml(page, html); + + const editor = editorLocator(page); + await expect(editor).toHaveScreenshot('custom-style-fonts-visual.png'); +}); + +test.describe('custom style fonts - toolbar interaction', () => { + test.beforeEach(async ({ page }) => { + await gotoVisualRegression(page); + }); + + test('apply font size then type text', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyFontSize(page, 20); + await editor.pressSequentially('Sized text', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + 'Quote mono family
Sized text
' + ); + }); + + test('clear font size stops sizing new text', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyFontSize(page, 20); + await editor.pressSequentially('Sized', { delay: 80 }); + await clearFontSize(page); + await editor.pressSequentially(' plain', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + 'Sized plain
' + ); + }); + + test('apply font family then type text', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyFontFamily(page, 'Georgia'); + await editor.pressSequentially('Serif text', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + 'Serif text
' + ); + }); + + test('clear font family stops styling new text', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyFontFamily(page, 'Georgia'); + await editor.pressSequentially('Serif', { delay: 80 }); + await clearFontFamily(page); + await editor.pressSequentially(' plain', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + 'Serif plain
' + ); + }); + + test('apply font size and font family together', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyFontSize(page, 24); + await applyFontFamily(page, 'Georgia'); + await editor.pressSequentially('Big serif', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + 'Big serif
' + ); + }); + + test('font size with bold', async ({ page }) => { + const editor = editorLocator(page); + const boldBtn = toolbarButton(page, 'bold'); + await editor.click(); + + await boldBtn.click(); + await applyFontSize(page, 32); + await editor.pressSequentially('Bold big', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + 'Bold big
' + ); + }); + + test('font family with italic', async ({ page }) => { + const editor = editorLocator(page); + const italicBtn = toolbarButton(page, 'italic'); + await editor.click(); + + await italicBtn.click(); + await applyFontFamily(page, 'Georgia'); + await editor.pressSequentially('Italic serif', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + 'Italic serif
' + ); + }); + + test('changing font size on a selection preserves per-run font family', async ({ + page, + }) => { + // Two runs with different families, then re-size the whole line and ensure + // each run keeps its own family (per-range merge, not a flattened span). + await setEditorHtml( + page, + [ + '', + 'One', + 'Two', + '
', + ].join('') + ); + + await focusEnrichedEditable(page); + await page.keyboard.press('ControlOrMeta+a'); + await applyFontSize(page, 20); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + [ + '', + 'One', + 'Two', + '
', + ].join('') + ); + }); + + test('toolbar font-size button shows active size when set', async ({ + page, + }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyFontSize(page, 20); + + await expect(fontSizeButton(page)).toContainText('20'); + await expect(fontSizeButton(page)).toHaveClass(/toolbar-btn--active/); + + // Re-open the picker – the chosen size should be marked as active + await fontSizeButton(page).click(); + await expect(fontSizeOption(page, 20)).toHaveClass( + /toolbar-font-option--active/ + ); + }); + + test('toolbar font-family button shows active family when set', async ({ + page, + }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyFontFamily(page, 'Georgia'); + + // The button shows the family's label ("Serif" maps to "Georgia"). + await expect(fontFamilyButton(page)).toContainText('Serif'); + await expect(fontFamilyButton(page)).toHaveClass(/toolbar-btn--active/); + + // Re-open the picker – the chosen family should be marked as active + await fontFamilyButton(page).click(); + await expect(fontFamilyOption(page, 'Georgia')).toHaveClass( + /toolbar-font-option--active/ + ); + }); +}); diff --git a/android/src/main/java/com/swmansion/enriched/common/ColorUtils.kt b/android/src/main/java/com/swmansion/enriched/common/ColorUtils.kt new file mode 100644 index 000000000..a939626b6 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/common/ColorUtils.kt @@ -0,0 +1,14 @@ +package com.swmansion.enriched.common + +import android.graphics.Color + +internal const val DEFAULT_BACKGROUND_OPACITY = 80 + +internal fun withOpacity( + color: Int, + alpha: Int = DEFAULT_BACKGROUND_OPACITY, +): Int { + if (Color.alpha(color) != 255) return color + val a = alpha.coerceIn(0, 255) + return (color and 0x00FFFFFF) or (a shl 24) +} diff --git a/android/src/main/java/com/swmansion/enriched/common/CustomStyle.kt b/android/src/main/java/com/swmansion/enriched/common/CustomStyle.kt new file mode 100644 index 000000000..76e8980ce --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/common/CustomStyle.kt @@ -0,0 +1,14 @@ +package com.swmansion.enriched.common + +data class CustomStyle( + val foregroundColor: Int? = null, + val backgroundColor: Int? = null, + val fontSize: Float? = null, + val fontFamily: String? = null, +) { + fun isEmpty(): Boolean = + foregroundColor == null && + backgroundColor == null && + (fontSize == null || fontSize <= 0f) && + fontFamily.isNullOrBlank() +} diff --git a/android/src/main/java/com/swmansion/enriched/common/EnrichedSpanFlags.kt b/android/src/main/java/com/swmansion/enriched/common/EnrichedSpanFlags.kt index d9a0ddb50..0466bbb0f 100644 --- a/android/src/main/java/com/swmansion/enriched/common/EnrichedSpanFlags.kt +++ b/android/src/main/java/com/swmansion/enriched/common/EnrichedSpanFlags.kt @@ -2,6 +2,7 @@ package com.swmansion.enriched.common import android.text.Spannable import com.swmansion.enriched.common.spans.EnrichedAlignmentSpan +import com.swmansion.enriched.common.spans.EnrichedCustomStyleSpan import com.swmansion.enriched.common.spans.interfaces.EnrichedInlineSpan // Higher priority spans are processed first, so styles with lower priorities are painted on top of previously applied styles. @@ -10,7 +11,8 @@ import com.swmansion.enriched.common.spans.interfaces.EnrichedInlineSpan object EnrichedSpanFlags { private const val ALIGNMENT_SPAN_PRIORITY = 0 private const val INLINE_SPAN_PRIORITY = 1 - private const val PARAGRAPH_SPAN_PRIORITY = 2 + private const val CUSTOM_STYLE_SPAN_PRIORITY = 2 + private const val PARAGRAPH_SPAN_PRIORITY = 3 @JvmStatic @JvmOverloads @@ -21,6 +23,7 @@ object EnrichedSpanFlags { val priority = when (span) { is EnrichedAlignmentSpan -> ALIGNMENT_SPAN_PRIORITY + is EnrichedCustomStyleSpan -> CUSTOM_STYLE_SPAN_PRIORITY is EnrichedInlineSpan -> INLINE_SPAN_PRIORITY else -> PARAGRAPH_SPAN_PRIORITY } diff --git a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedColorParser.kt b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedColorParser.kt new file mode 100644 index 000000000..fdac22e87 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedColorParser.kt @@ -0,0 +1,273 @@ +package com.swmansion.enriched.common.parser + +import android.graphics.Color +import androidx.core.graphics.toColorInt +import kotlin.math.roundToInt + +object EnrichedColorParser { + private val CSS_NAMED_COLORS = + mapOf( + "aliceblue" to "#F0F8FFFF", + "antiquewhite" to "#FAEBD7FF", + "aqua" to "#00FFFFFF", + "aquamarine" to "#7FFFD4FF", + "azure" to "#F0FFFFFF", + "beige" to "#F5F5DCFF", + "bisque" to "#FFE4C4FF", + "black" to "#000000FF", + "blanchedalmond" to "#FFEBCDFF", + "blue" to "#0000FFFF", + "blueviolet" to "#8A2BE2FF", + "brown" to "#A52A2AFF", + "burlywood" to "#DEB887FF", + "cadetblue" to "#5F9EA0FF", + "chartreuse" to "#7FFF00FF", + "chocolate" to "#D2691EFF", + "coral" to "#FF7F50FF", + "cornflowerblue" to "#6495EDFF", + "cornsilk" to "#FFF8DCFF", + "crimson" to "#DC143CFF", + "cyan" to "#00FFFFFF", + "darkblue" to "#00008BFF", + "darkcyan" to "#008B8BFF", + "darkgoldenrod" to "#B8860BFF", + "darkgray" to "#A9A9A9FF", + "darkgrey" to "#A9A9A9FF", + "darkgreen" to "#006400FF", + "darkkhaki" to "#BDB76BFF", + "darkmagenta" to "#8B008BFF", + "darkolivegreen" to "#556B2FFF", + "darkorange" to "#FF8C00FF", + "darkorchid" to "#9932CCFF", + "darkred" to "#8B0000FF", + "darksalmon" to "#E9967AFF", + "darkseagreen" to "#8FBC8FFF", + "darkslateblue" to "#483D8BFF", + "darkslategray" to "#2F4F4FFF", + "darkslategrey" to "#2F4F4FFF", + "darkturquoise" to "#00CED1FF", + "darkviolet" to "#9400D3FF", + "deeppink" to "#FF1493FF", + "deepskyblue" to "#00BFFFFF", + "dimgray" to "#696969FF", + "dimgrey" to "#696969FF", + "dodgerblue" to "#1E90FFFF", + "firebrick" to "#B22222FF", + "floralwhite" to "#FFFAF0FF", + "forestgreen" to "#228B22FF", + "fuchsia" to "#FF00FFFF", + "gainsboro" to "#DCDCDCFF", + "ghostwhite" to "#F8F8FFFF", + "gold" to "#FFD700FF", + "goldenrod" to "#DAA520FF", + "gray" to "#808080FF", + "grey" to "#808080FF", + "green" to "#008000FF", + "greenyellow" to "#ADFF2FFF", + "honeydew" to "#F0FFF0FF", + "hotpink" to "#FF69B4FF", + "indianred" to "#CD5C5CFF", + "indigo" to "#4B0082FF", + "ivory" to "#FFFFF0FF", + "khaki" to "#F0E68CFF", + "lavender" to "#E6E6FAFF", + "lavenderblush" to "#FFF0F5FF", + "lawngreen" to "#7CFC00FF", + "lemonchiffon" to "#FFFACDFF", + "lightblue" to "#ADD8E6FF", + "lightcoral" to "#F08080FF", + "lightcyan" to "#E0FFFFFF", + "lightgoldenrodyellow" to "#FAFAD2FF", + "lightgray" to "#D3D3D3FF", + "lightgrey" to "#D3D3D3FF", + "lightgreen" to "#90EE90FF", + "lightpink" to "#FFB6C1FF", + "lightsalmon" to "#FFA07AFF", + "lightseagreen" to "#20B2AAFF", + "lightskyblue" to "#87CEFAFF", + "lightslategray" to "#778899FF", + "lightslategrey" to "#778899FF", + "lightsteelblue" to "#B0C4DEFF", + "lightyellow" to "#FFFFE0FF", + "lime" to "#00FF00FF", + "limegreen" to "#32CD32FF", + "linen" to "#FAF0E6FF", + "magenta" to "#FF00FFFF", + "maroon" to "#800000FF", + "mediumaquamarine" to "#66CDAAFF", + "mediumblue" to "#0000CDFF", + "mediumorchid" to "#BA55D3FF", + "mediumpurple" to "#9370D8FF", + "mediumseagreen" to "#3CB371FF", + "mediumslateblue" to "#7B68EEFF", + "mediumspringgreen" to "#00FA9AFF", + "mediumturquoise" to "#48D1CCFF", + "mediumvioletred" to "#C71585FF", + "midnightblue" to "#191970FF", + "mintcream" to "#F5FFFAFF", + "mistyrose" to "#FFE4E1FF", + "moccasin" to "#FFE4B5FF", + "navajowhite" to "#FFDEADFF", + "navy" to "#000080FF", + "oldlace" to "#FDF5E6FF", + "olive" to "#808000FF", + "olivedrab" to "#6B8E23FF", + "orange" to "#FFA500FF", + "orangered" to "#FF4500FF", + "orchid" to "#DA70D6FF", + "palegoldenrod" to "#EEE8AAFF", + "palegreen" to "#98FB98FF", + "paleturquoise" to "#AFEEEEFF", + "palevioletred" to "#D87093FF", + "papayawhip" to "#FFEFD5FF", + "peachpuff" to "#FFDAB9FF", + "peru" to "#CD853FFF", + "pink" to "#FFC0CBFF", + "plum" to "#DDA0DDFF", + "powderblue" to "#B0E0E6FF", + "purple" to "#800080FF", + "rebeccapurple" to "#663399FF", + "red" to "#FF0000FF", + "rosybrown" to "#BC8F8FFF", + "royalblue" to "#4169E1FF", + "saddlebrown" to "#8B4513FF", + "salmon" to "#FA8072FF", + "sandybrown" to "#F4A460FF", + "seagreen" to "#2E8B57FF", + "seashell" to "#FFF5EEFF", + "sienna" to "#A0522DFF", + "silver" to "#C0C0C0FF", + "skyblue" to "#87CEEBFF", + "slateblue" to "#6A5ACDFF", + "slategray" to "#708090FF", + "slategrey" to "#708090FF", + "snow" to "#FFFAFAFF", + "springgreen" to "#00FF7FFF", + "steelblue" to "#4682B4FF", + "tan" to "#D2B48CFF", + "teal" to "#008080FF", + "thistle" to "#D8BFD8FF", + "tomato" to "#FF6347FF", + "turquoise" to "#40E0D0FF", + "violet" to "#EE82EEFF", + "wheat" to "#F5DEB3FF", + "white" to "#FFFFFFFF", + "whitesmoke" to "#F5F5F5FF", + "yellow" to "#FFFF00FF", + "yellowgreen" to "#9ACD32FF", + ) + + @JvmStatic + fun parseCssColor(value: String?): Int? { + if (value.isNullOrBlank()) return null + + var str = value.trim().lowercase() + + // Handle Named Colors + if (CSS_NAMED_COLORS.containsKey(str)) { + str = CSS_NAMED_COLORS[str]!! + } + + // Handle Hex (#FFF, #FFFFFF, #FFFFFFFF CSS format) + if (str.startsWith("#")) { + val hex = str.substring(1) + return try { + when (hex.length) { + 3 -> { + // #RGB -> #RRGGBB + val expanded = "${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}" + "#$expanded".toColorInt() + } + + 4 -> { + // #RGBA -> #AARRGGBB + val expanded = "${hex[3]}${hex[3]}${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}" + "#$expanded".toColorInt() + } + + 6 -> { + // #RRGGBB + str.toColorInt() + } + + 8 -> { + // #RRGGBBAA -> #AARRGGBB + val aarrggbb = "#${hex.substring(6, 8)}${hex.substring(0, 6)}" + aarrggbb.toColorInt() + } + + else -> { + null + } + } + } catch (_: IllegalArgumentException) { + null + } + } + + // Handle rgb() and rgba() + if (str.startsWith("rgb")) { + return try { + val start = str.indexOf('(') + 1 + val end = str.indexOf(')') + if (start <= 0 || end <= start) return null + + val parts = str.substring(start, end).split(",") + if (parts.size >= 3) { + val r = + parts[0] + .trim() + .toFloat() + .roundToInt() + .coerceIn(0, 255) + val g = + parts[1] + .trim() + .toFloat() + .roundToInt() + .coerceIn(0, 255) + val b = + parts[2] + .trim() + .toFloat() + .roundToInt() + .coerceIn(0, 255) + + val a = + if (parts.size == 4) { + (parts[3].trim().toFloat() * 255f).roundToInt().coerceIn(0, 255) + } else { + 255 + } + + Color.argb(a, r, g, b) + } else { + null + } + } catch (_: Exception) { + null + } + } + + // Catch any native Android colors if missed + return try { + str.toColorInt() + } catch (_: IllegalArgumentException) { + null + } + } + + @JvmStatic + fun colorToHex(color: Int): String { + val alpha = (color ushr 24) and 0xFF + val red = (color shr 16) and 0xFF + val green = (color shr 8) and 0xFF + val blue = color and 0xFF + + return if (alpha == 255) { + String.format("#%02X%02X%02X", red, green, blue) + } else { + String.format("#%02X%02X%02X%02X", red, green, blue, alpha) + } + } +} diff --git a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java index 53d761c38..4188480a5 100644 --- a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java +++ b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java @@ -6,12 +6,14 @@ import android.text.Spanned; import android.text.TextUtils; import android.text.style.ParagraphStyle; +import com.swmansion.enriched.common.CustomStyle; import com.swmansion.enriched.common.EnrichedConstants; import com.swmansion.enriched.common.EnrichedSpanFlags; import com.swmansion.enriched.common.spans.EnrichedAlignmentSpan; import com.swmansion.enriched.common.spans.EnrichedBoldSpan; import com.swmansion.enriched.common.spans.EnrichedCheckboxListSpan; import com.swmansion.enriched.common.spans.EnrichedCodeBlockSpan; +import com.swmansion.enriched.common.spans.EnrichedCustomStyleSpan; import com.swmansion.enriched.common.spans.EnrichedH1Span; import com.swmansion.enriched.common.spans.EnrichedH2Span; import com.swmansion.enriched.common.spans.EnrichedH3Span; @@ -345,6 +347,44 @@ private static void withinParagraph(StringBuilder out, Spanned text, int start, // Don't output the placeholder character underlying the image. i = next; } + if (style[j] instanceof EnrichedCustomStyleSpan) { + EnrichedCustomStyleSpan cs = (EnrichedCustomStyleSpan) style[j]; + Integer fgColor = cs.getForegroundColor(); + Integer bgColor = cs.getBackgroundColor(); + Float fontSize = cs.getFontSize(); + String fontFamily = cs.getFontFamily(); + if (!cs.toCustomStyle().isEmpty()) { + StringBuilder cssProps = new StringBuilder(); + if (fgColor != null) { + cssProps + .append("color: ") + .append(EnrichedColorParser.colorToHex(fgColor)) + .append(";"); + } + if (bgColor != null) { + if (cssProps.length() > 0) cssProps.append(" "); + cssProps + .append("background-color: ") + .append(EnrichedColorParser.colorToHex(bgColor)) + .append(";"); + } + if (fontSize != null) { + if (cssProps.length() > 0) cssProps.append(" "); + cssProps.append("font-size: ").append(formatCssFontSizeValue(fontSize)).append("px;"); + } + if (fontFamily != null && !fontFamily.isEmpty()) { + if (cssProps.length() > 0) cssProps.append(" "); + if (fontFamily.indexOf(' ') >= 0) { + cssProps.append("font-family: '").append(fontFamily).append("';"); + } else { + cssProps.append("font-family: ").append(fontFamily).append(";"); + } + } + out.append(""); + } else { + out.append(""); + } + } } withinStyle(out, text, i, next); for (int j = style.length - 1; j >= 0; j--) { @@ -369,6 +409,9 @@ private static void withinParagraph(StringBuilder out, Spanned text, int start, if (style[j] instanceof EnrichedItalicSpan) { out.append(""); } + if (style[j] instanceof EnrichedCustomStyleSpan) { + out.append(""); + } } } } @@ -407,6 +450,13 @@ private static void withinStyle(StringBuilder out, CharSequence text, int start, } } } + + private static String formatCssFontSizeValue(float fontSize) { + if (fontSize == Math.rint(fontSize) && !Float.isInfinite(fontSize)) { + return String.valueOf((int) fontSize); + } + return String.valueOf(fontSize); + } } class HtmlToSpannedConverter`, ``, `` | ``, `
` |
| Mention | `` | ``, `` | ``, `
` |
| Image | `
` | ``, `` | `` |
+| Custom style | `` | -- | -- |
+
+`` carries the [custom style](/rich-text-formatting/custom-style) in its
+`style` attribute - `color`, `background-color`, `font-size` and `font-family`.
:::note
diff --git a/docs/docs/rich-text-formatting/basic-styles.mdx b/docs/docs/rich-text-formatting/basic-styles.mdx
index ce52600e2..41e9b8395 100644
--- a/docs/docs/rich-text-formatting/basic-styles.mdx
+++ b/docs/docs/rich-text-formatting/basic-styles.mdx
@@ -35,7 +35,7 @@ once.
:::note
-There are two more inline styles, which you cannot toggle quite like the others - **links** and **mentions**. You'll learn about them in their respective sections, once you finish this chapter.
+There are a few more inline styles, which you cannot toggle quite like the others - **custom style**, **links** and **mentions**. You'll learn about them in their respective sections, once you finish this chapter.
:::
diff --git a/docs/docs/rich-text-formatting/custom-style.mdx b/docs/docs/rich-text-formatting/custom-style.mdx
new file mode 100644
index 000000000..4c3789f25
--- /dev/null
+++ b/docs/docs/rich-text-formatting/custom-style.mdx
@@ -0,0 +1,98 @@
+---
+sidebar_position: 2
+---
+
+import InteractiveExample from '@site/src/components/InteractiveExample';
+import CustomStyleEditor from '@site/src/examples/CustomStyleEditor';
+import CustomStyleEditorSrc from '!!raw-loader!@site/src/examples/CustomStyleEditor';
+
+# Custom style
+
+Aside from the predefined [basic styles](/rich-text-formatting/basic-styles)
+you can apply a **custom style**, which consists of four pieces of formatting:
+text color, highlight, font size and font family. Similarly as the style-toggling
+methods, it applies to the current input's selection.
+
+## Setting a custom style
+
+`setStyle` accepts an object with four optional fields:
+
+```tsx
+setStyle(
+ {
+ foregroundColor?: string,
+ backgroundColor?: string,
+ fontSize?: number,
+ fontFamily?: string,
+ }
+);
+```
+
+Each call respects the already present custom style and modifies it with whatever
+fields you pass. For example, if you want to remove a single field, pass `null`:
+
+```tsx
+// Remove the highlight, keep the color, size and font as they are.
+ref.current?.setStyle({ backgroundColor: null });
+```
+
+Custom style doesn't conflict with or block any other style, so it combines
+freely with both inline styles and paragraph styles.
+
+:::note
+
+If a current style modifies the same thing that **custom style** sets, we have the following priority:
+**paragraph style** < **custom style** < **inline style**. E.g. if you try to apply `foregroundColor` custom style to a text with a link (which is an inline style), the link's foreground color stays.
+
+:::
+
+## Reading the current style
+
+Since custom style carries values rather than a simple on/off flag, `onChangeState`
+reports it as a separate `customStyle` object instead of the usual
+`isActive` / `isBlocking` / `isConflicting`
+[booleans](/fundamentals/core-concepts#the-style-state-model):
+
+```ts
+customStyle: {
+ foregroundColor: string;
+ backgroundColor: string;
+ fontSize: number;
+ fontFamily: string;
+}
+```
+
+Each field holds the value at the current selection. When a field isn't set, it comes back
+empty: `''` for strings and `0` for `fontSize`. That makes it easy to check whether a field
+is applied:
+
+```tsx
+const customStyleButtons = [
+ {
+ label: 'Color',
+ isActive: !!state?.customStyle.foregroundColor,
+ onPress: () => ref.current?.setStyle({ foregroundColor: '#e5484d' }),
+ },
+ {
+ label: 'Font size',
+ isActive: !!state?.customStyle.fontSize,
+ onPress: () => ref.current?.setStyle({ fontSize: 26 }),
+ },
+];
+```
+
+:::tip
+
+Pass colors to `setStyle` as hex or named colors (e.g. `'red'`). They are
+always reported as uppercase hex strings - `#RRGGBB`, or `#RRGGBBAA`.
+
+:::
+
+## Try it out
+
+Each button below sets one field of the custom style on the selection, and
+removes it when the field is already applied. Select a word and give it a color,
+then a highlight - notice how the second call doesn't touch the first one. Try
+mixing them with different fonts and sizes.
+
+
diff --git a/docs/docs/rich-text-formatting/inline-images.mdx b/docs/docs/rich-text-formatting/inline-images.mdx
index 4677ad594..a88b448a5 100644
--- a/docs/docs/rich-text-formatting/inline-images.mdx
+++ b/docs/docs/rich-text-formatting/inline-images.mdx
@@ -1,5 +1,5 @@
---
-sidebar_position: 5
+sidebar_position: 6
---
import InteractiveExample from '@site/src/components/InteractiveExample';
diff --git a/docs/docs/rich-text-formatting/links.mdx b/docs/docs/rich-text-formatting/links.mdx
index 556ace638..7df5a5070 100644
--- a/docs/docs/rich-text-formatting/links.mdx
+++ b/docs/docs/rich-text-formatting/links.mdx
@@ -1,5 +1,5 @@
---
-sidebar_position: 2
+sidebar_position: 3
---
import InteractiveExample from '@site/src/components/InteractiveExample';
diff --git a/docs/docs/rich-text-formatting/lists.mdx b/docs/docs/rich-text-formatting/lists.mdx
index 118cf1d57..c8beb3338 100644
--- a/docs/docs/rich-text-formatting/lists.mdx
+++ b/docs/docs/rich-text-formatting/lists.mdx
@@ -1,5 +1,5 @@
---
-sidebar_position: 4
+sidebar_position: 5
---
import InteractiveExample from '@site/src/components/InteractiveExample';
diff --git a/docs/docs/rich-text-formatting/mentions.mdx b/docs/docs/rich-text-formatting/mentions.mdx
index 147a1d1e8..17142018d 100644
--- a/docs/docs/rich-text-formatting/mentions.mdx
+++ b/docs/docs/rich-text-formatting/mentions.mdx
@@ -1,5 +1,5 @@
---
-sidebar_position: 3
+sidebar_position: 4
---
import InteractiveExample from '@site/src/components/InteractiveExample';
diff --git a/docs/docs/rich-text-formatting/text-alignment.mdx b/docs/docs/rich-text-formatting/text-alignment.mdx
index 9bca7329e..9dc27f4cd 100644
--- a/docs/docs/rich-text-formatting/text-alignment.mdx
+++ b/docs/docs/rich-text-formatting/text-alignment.mdx
@@ -1,5 +1,5 @@
---
-sidebar_position: 6
+sidebar_position: 7
---
import InteractiveExample from '@site/src/components/InteractiveExample';
diff --git a/docs/docs/rich-text-formatting/text-shortcuts.mdx b/docs/docs/rich-text-formatting/text-shortcuts.mdx
index 4a5e19938..551b36a73 100644
--- a/docs/docs/rich-text-formatting/text-shortcuts.mdx
+++ b/docs/docs/rich-text-formatting/text-shortcuts.mdx
@@ -1,5 +1,5 @@
---
-sidebar_position: 7
+sidebar_position: 8
---
import InteractiveExample from '@site/src/components/InteractiveExample';
diff --git a/docs/src/examples/CustomStyleEditor.tsx b/docs/src/examples/CustomStyleEditor.tsx
new file mode 100644
index 000000000..c52e5fc09
--- /dev/null
+++ b/docs/src/examples/CustomStyleEditor.tsx
@@ -0,0 +1,117 @@
+import { EnrichedTextInput } from 'react-native-enriched-html';
+import type {
+ EnrichedTextInputInstance,
+ OnChangeStateEvent,
+} from 'react-native-enriched-html';
+import { useRef, useState } from 'react';
+import { View, StyleSheet, Pressable, Text } from 'react-native';
+import { htmlStyle } from './htmlStyle';
+
+const TEXT_COLOR = '#ff8560';
+const HIGHLIGHT_COLOR = '#c1c6e5';
+const FONT_SIZE = 26;
+const FONT_FAMILY = 'cursive';
+
+export default function App() {
+ const ref = useRef(null);
+ const [state, setState] = useState(null);
+
+ const customStyle = state?.customStyle;
+
+ const buttons = [
+ {
+ label: 'Color',
+ isActive: !!customStyle?.foregroundColor,
+ onPress: () =>
+ ref.current?.setStyle({
+ foregroundColor: customStyle?.foregroundColor ? null : TEXT_COLOR,
+ }),
+ },
+ {
+ label: 'Highlight',
+ isActive: !!customStyle?.backgroundColor,
+ onPress: () =>
+ ref.current?.setStyle({
+ backgroundColor: customStyle?.backgroundColor
+ ? null
+ : HIGHLIGHT_COLOR,
+ }),
+ },
+ {
+ label: 'Font size',
+ isActive: !!customStyle?.fontSize,
+ onPress: () =>
+ ref.current?.setStyle({
+ fontSize: customStyle?.fontSize ? null : FONT_SIZE,
+ }),
+ },
+ {
+ label: 'Font family',
+ isActive: !!customStyle?.fontFamily,
+ onPress: () =>
+ ref.current?.setStyle({
+ fontFamily: customStyle?.fontFamily ? null : FONT_FAMILY,
+ }),
+ },
+ ];
+
+ return (
+
+ setState(e.nativeEvent)}
+ />
+
+ {buttons.map(button => (
+
+
+ {button.label}
+
+
+ ))}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { gap: 12 },
+ input: {
+ fontSize: 18,
+ color: '#232736',
+ padding: 12,
+ borderRadius: 12,
+ minHeight: 96,
+ backgroundColor: '#eef0ff',
+ },
+ row: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ justifyContent: 'center',
+ gap: 8,
+ },
+ button: {
+ width: 120,
+ padding: 8,
+ borderRadius: 24,
+ borderWidth: 1,
+ borderColor: '#919fcf',
+ },
+ buttonActive: {
+ borderColor: '#57b495',
+ backgroundColor: '#57b495',
+ },
+ text: {
+ textAlign: 'center',
+ color: '#919fcf',
+ },
+ textActive: {
+ color: '#eef0ff',
+ },
+});
diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm
index c44d4d9dd..f4f5ab927 100644
--- a/ios/EnrichedTextInputView.mm
+++ b/ios/EnrichedTextInputView.mm
@@ -1,5 +1,6 @@
#import "EnrichedTextInputView.h"
#import "CoreText/CoreText.h"
+#import "extensions/ColorExtension.h"
#import "extensions/LayoutManagerExtension.h"
#import "extensions/StringExtension.h"
#import "generated/ReactCodegen/ReactNativeEnrichedSpec/EventEmitters.h"
@@ -21,6 +22,7 @@
#import "utils/WordsUtils.h"
#import "utils/ZeroWidthSpaceUtils.h"
#import
+#import
#import
#import
#import
@@ -62,6 +64,7 @@ @implementation EnrichedTextInputView {
NSString *_submitBehavior;
NSDictionary *_capturedAttributesBeforeChange;
NSString *_recentlyEmittedAlignment;
+ CustomStyleData *_recentlyEmittedCustomStyle;
}
@synthesize blockEmitting = blockEmitting;
@@ -1135,6 +1138,15 @@ - (void)tryUpdatingActiveStyles {
updateNeeded = YES;
}
+ // detect custom style change
+ CustomStyle *customStyle = stylesDict[@([CustomStyle getType])];
+ CustomStyleData *currentCustomStyle =
+ [customStyle getCustomStyleDataInRange:textView.selectedRange];
+ if (currentCustomStyle != _recentlyEmittedCustomStyle &&
+ ![currentCustomStyle isEqual:_recentlyEmittedCustomStyle]) {
+ updateNeeded = YES;
+ }
+
if (updateNeeded) {
auto emitter = [self getEventEmitter];
if (emitter != nullptr) {
@@ -1142,6 +1154,7 @@ - (void)tryUpdatingActiveStyles {
_activeStyles = newActiveStyles;
_blockedStyles = newBlockedStyles;
_recentlyEmittedAlignment = currentAlignment;
+ _recentlyEmittedCustomStyle = currentCustomStyle;
emitter->onChangeState(
{.bold = GET_STYLE_STATE([BoldStyle getType]),
@@ -1163,7 +1176,19 @@ - (void)tryUpdatingActiveStyles {
.codeBlock = GET_STYLE_STATE([CodeBlockStyle getType]),
.image = GET_STYLE_STATE([ImageStyle getType]),
.checkboxList = GET_STYLE_STATE([CheckboxListStyle getType]),
- .alignment = [currentAlignment UTF8String]});
+ .alignment = [currentAlignment UTF8String],
+ .customStyle = {
+ .foregroundColor =
+ [[currentCustomStyle.foregroundColor hexString] UTF8String]
+ ?: "",
+ .backgroundColor =
+ [[currentCustomStyle.backgroundColor hexString] UTF8String]
+ ?: "",
+ .fontSize = currentCustomStyle.fontSize
+ ? [currentCustomStyle.fontSize doubleValue]
+ : 0.0,
+ .fontFamily =
+ [currentCustomStyle.fontFamily UTF8String] ?: ""}});
}
}
@@ -1311,6 +1336,9 @@ - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args {
if (!_placeholderLabel.isHidden) {
[self refreshPlaceholderLabelStyles];
}
+ } else if ([commandName isEqualToString:@"setStyle"]) {
+ NSString *styleJSON = (NSString *)args[0];
+ [self setStyle:styleJSON];
}
}
@@ -1518,6 +1546,66 @@ - (void)toggleRegularStyle:(StyleType)type {
}
}
+- (void)setStyle:(NSString *)styleJSON {
+ NSData *jsonData = [styleJSON dataUsingEncoding:NSUTF8StringEncoding];
+ if (jsonData == nil)
+ return;
+ id parsed = [NSJSONSerialization JSONObjectWithData:jsonData
+ options:0
+ error:nil];
+ if (![parsed isKindOfClass:[NSDictionary class]])
+ return;
+ NSDictionary *dict = (NSDictionary *)parsed;
+
+ NSRange selectedRange = textView.selectedRange;
+ CustomStyle *customStyleClass =
+ (CustomStyle *)stylesDict[@([CustomStyle getType])];
+ if (customStyleClass == nil)
+ return;
+
+ if (![StyleUtils handleStyleBlocksAndConflicts:[CustomStyle getType]
+ range:selectedRange
+ forHost:self]) {
+ return;
+ }
+
+ // Convert raw JSON values (NSNumber ARGB integers from processColor) to
+ // UIColor. NSNull is passed through as-is so mergeFromDict: can clear
+ // the color when the caller explicitly passes null.
+ NSMutableDictionary *processedDict = [NSMutableDictionary new];
+
+ id fgRaw = dict[@"foregroundColor"];
+ if (fgRaw != nil) {
+ processedDict[@"foregroundColor"] = [fgRaw isKindOfClass:[NSNull class]]
+ ? [NSNull null]
+ : [RCTConvert UIColor:fgRaw];
+ }
+
+ id bgRaw = dict[@"backgroundColor"];
+ if (bgRaw != nil) {
+ processedDict[@"backgroundColor"] = [bgRaw isKindOfClass:[NSNull class]]
+ ? [NSNull null]
+ : [RCTConvert UIColor:bgRaw];
+ }
+
+ id fsRaw = dict[@"fontSize"];
+ if (fsRaw != nil) {
+ processedDict[@"fontSize"] =
+ [fsRaw isKindOfClass:[NSNull class]] ? [NSNull null] : fsRaw;
+ }
+
+ id ffRaw = dict[@"fontFamily"];
+ if (ffRaw != nil) {
+ processedDict[@"fontFamily"] = [ffRaw isKindOfClass:[NSNull class]]
+ ? [NSNull null]
+ : [RCTConvert NSString:ffRaw];
+ }
+
+ [customStyleClass applyStyleFromDict:processedDict
+ selectedRange:selectedRange];
+ [self anyTextMayHaveBeenModified];
+}
+
- (void)toggleCheckboxList:(BOOL)checked {
CheckboxListStyle *style =
(CheckboxListStyle *)stylesDict[@([CheckboxListStyle getType])];
@@ -1886,6 +1974,10 @@ - (void)emitOnContextMenuItemPressEvent:(NSString *)itemText {
AlignmentStyle *alignmentStyle = stylesDict[@([AlignmentStyle getType])];
NSString *currentAlignment = [alignmentStyle getStyleState];
+ CustomStyle *customStyle = stylesDict[@([CustomStyle getType])];
+ CustomStyleData *contextCustomStyleData =
+ [customStyle getCustomStyleDataInRange:textView.selectedRange];
+
emitter->onContextMenuItemPress(
{.itemText = [itemText toCppString],
.selectedText = [selectedText toCppString],
@@ -1912,7 +2004,21 @@ - (void)emitOnContextMenuItemPressEvent:(NSString *)itemText {
.image = GET_STYLE_STATE([ImageStyle getType]),
.mention = GET_STYLE_STATE([MentionStyle getType]),
.checkboxList = GET_STYLE_STATE([CheckboxListStyle getType]),
- .alignment = [currentAlignment UTF8String]}});
+ .alignment = [currentAlignment UTF8String],
+ .customStyle = {
+ .foregroundColor =
+ [[contextCustomStyleData.foregroundColor hexString]
+ UTF8String]
+ ?: "",
+ .backgroundColor =
+ [[contextCustomStyleData.backgroundColor hexString]
+ UTF8String]
+ ?: "",
+ .fontSize = contextCustomStyleData.fontSize
+ ? [contextCustomStyleData.fontSize doubleValue]
+ : 0.0,
+ .fontFamily =
+ [contextCustomStyleData.fontFamily UTF8String] ?: ""}}});
}
}
@@ -1939,8 +2045,9 @@ - (bool)textView:(UITextView *)textView
replacementText:(NSString *)text {
// Capture the attributes at range.location that are being replaced
// (autocorrect / predictive) so didProcessEditing: can re-stamp them onto the
- // replacement. Only capture for genuine replacements (text.length > 0), not
- // for deletions/backspace (text.length == 0).
+ // replacement. Skip pure deletions (text.length == 0) — there is no incoming
+ // text to receive attributes, and capturing here would cause the deleted
+ // character's CustomStyleData to be re-stamped onto the widened editedRange.
if (range.length > 0 && text.length > 0) {
_capturedAttributesBeforeChange =
[textView.textStorage attributesAtIndex:range.location
diff --git a/ios/config/EnrichedConfig.h b/ios/config/EnrichedConfig.h
index be7b8eb7b..7f3234e6c 100644
--- a/ios/config/EnrichedConfig.h
+++ b/ios/config/EnrichedConfig.h
@@ -89,6 +89,7 @@
- (CGFloat)codeBlockBorderRadius;
- (void)setCodeBlockBorderRadius:(CGFloat)newValue;
- (void)invalidateFonts;
+- (CGFloat)scaledFontSizeForValue:(CGFloat)fontSize;
- (NSNumber *)scaledPrimaryFontSize;
- (BOOL)allowFontScaling;
- (void)setAllowFontScaling:(BOOL)newValue;
diff --git a/ios/config/EnrichedConfig.mm b/ios/config/EnrichedConfig.mm
index 89f0e9d1d..fbb1829e4 100644
--- a/ios/config/EnrichedConfig.mm
+++ b/ios/config/EnrichedConfig.mm
@@ -162,11 +162,7 @@ - (void)setPrimaryLineHeight:(CGFloat)newValue {
}
- (CGFloat)scaledPrimaryLineHeight {
- if (!_allowFontScaling) {
- return [self primaryLineHeight];
- }
- return [[UIFontMetrics defaultMetrics]
- scaledValueForValue:[self primaryLineHeight]];
+ return [self scaledFontSizeForValue:[self primaryLineHeight]];
}
- (NSString *)primaryFontWeight {
@@ -235,10 +231,7 @@ - (void)setMentionIndicators:(NSSet *)newValue {
}
- (CGFloat)h1FontSize {
- if (!_allowFontScaling) {
- return _h1FontSize;
- }
- return [[UIFontMetrics defaultMetrics] scaledValueForValue:_h1FontSize];
+ return [self scaledFontSizeForValue:_h1FontSize];
}
- (void)setH1FontSize:(CGFloat)newValue {
@@ -254,10 +247,7 @@ - (void)setH1Bold:(BOOL)newValue {
}
- (CGFloat)h2FontSize {
- if (!_allowFontScaling) {
- return _h2FontSize;
- }
- return [[UIFontMetrics defaultMetrics] scaledValueForValue:_h2FontSize];
+ return [self scaledFontSizeForValue:_h2FontSize];
}
- (void)setH2FontSize:(CGFloat)newValue {
@@ -273,10 +263,7 @@ - (void)setH2Bold:(BOOL)newValue {
}
- (CGFloat)h3FontSize {
- if (!_allowFontScaling) {
- return _h3FontSize;
- }
- return [[UIFontMetrics defaultMetrics] scaledValueForValue:_h3FontSize];
+ return [self scaledFontSizeForValue:_h3FontSize];
}
- (void)setH3FontSize:(CGFloat)newValue {
@@ -292,10 +279,7 @@ - (void)setH3Bold:(BOOL)newValue {
}
- (CGFloat)h4FontSize {
- if (!_allowFontScaling) {
- return _h4FontSize;
- }
- return [[UIFontMetrics defaultMetrics] scaledValueForValue:_h4FontSize];
+ return [self scaledFontSizeForValue:_h4FontSize];
}
- (void)setH4FontSize:(CGFloat)newValue {
@@ -311,10 +295,7 @@ - (void)setH4Bold:(BOOL)newValue {
}
- (CGFloat)h5FontSize {
- if (!_allowFontScaling) {
- return _h5FontSize;
- }
- return [[UIFontMetrics defaultMetrics] scaledValueForValue:_h5FontSize];
+ return [self scaledFontSizeForValue:_h5FontSize];
}
- (void)setH5FontSize:(CGFloat)newValue {
@@ -330,10 +311,7 @@ - (void)setH5Bold:(BOOL)newValue {
}
- (CGFloat)h6FontSize {
- if (!_allowFontScaling) {
- return _h6FontSize;
- }
- return [[UIFontMetrics defaultMetrics] scaledValueForValue:_h6FontSize];
+ return [self scaledFontSizeForValue:_h6FontSize];
}
- (void)setH6FontSize:(CGFloat)newValue {
@@ -560,13 +538,15 @@ - (void)setAllowFontScaling:(BOOL)newValue {
}
}
-- (NSNumber *)scaledPrimaryFontSize {
+- (CGFloat)scaledFontSizeForValue:(CGFloat)fontSize {
if (!_allowFontScaling) {
- return [self primaryFontSize];
+ return fontSize;
}
- CGFloat scaledSize = [[UIFontMetrics defaultMetrics]
- scaledValueForValue:[[self primaryFontSize] floatValue]];
- return @(scaledSize);
+ return [[UIFontMetrics defaultMetrics] scaledValueForValue:fontSize];
+}
+
+- (NSNumber *)scaledPrimaryFontSize {
+ return @([self scaledFontSizeForValue:[[self primaryFontSize] floatValue]]);
}
- (CGFloat)checkboxListBoxSize {
diff --git a/ios/extensions/ColorExtension.h b/ios/extensions/ColorExtension.h
index c3a2323c9..fab70da64 100644
--- a/ios/extensions/ColorExtension.h
+++ b/ios/extensions/ColorExtension.h
@@ -5,4 +5,6 @@
- (BOOL)isEqualToColor:(UIColor *)otherColor;
- (UIColor *)colorWithResolvedAlpha;
- (UIColor *)colorWithResolvedAlpha:(CGFloat)newAlpha;
+- (NSString *)hexString;
++ (UIColor *_Nullable)colorFromCSSString:(NSString *_Nullable)cssString;
@end
diff --git a/ios/extensions/ColorExtension.mm b/ios/extensions/ColorExtension.mm
index 32877787f..8c1f7ff9a 100644
--- a/ios/extensions/ColorExtension.mm
+++ b/ios/extensions/ColorExtension.mm
@@ -1,5 +1,163 @@
#import "ColorExtension.h"
+static NSDictionary *getNamedHexColors(void) {
+ static NSDictionary *namedColorHexes = nil;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ namedColorHexes = @{
+ @"aliceblue" : @"#F0F8FFFF",
+ @"antiquewhite" : @"#FAEBD7FF",
+ @"aqua" : @"#00FFFFFF",
+ @"aquamarine" : @"#7FFFD4FF",
+ @"azure" : @"#F0FFFFFF",
+ @"beige" : @"#F5F5DCFF",
+ @"bisque" : @"#FFE4C4FF",
+ @"black" : @"#000000FF",
+ @"blanchedalmond" : @"#FFEBCDFF",
+ @"blue" : @"#0000FFFF",
+ @"blueviolet" : @"#8A2BE2FF",
+ @"brown" : @"#A52A2AFF",
+ @"burlywood" : @"#DEB887FF",
+ @"cadetblue" : @"#5F9EA0FF",
+ @"chartreuse" : @"#7FFF00FF",
+ @"chocolate" : @"#D2691EFF",
+ @"coral" : @"#FF7F50FF",
+ @"cornflowerblue" : @"#6495EDFF",
+ @"cornsilk" : @"#FFF8DCFF",
+ @"crimson" : @"#DC143CFF",
+ @"cyan" : @"#00FFFFFF",
+ @"darkblue" : @"#00008BFF",
+ @"darkcyan" : @"#008B8BFF",
+ @"darkgoldenrod" : @"#B8860BFF",
+ @"darkgray" : @"#A9A9A9FF",
+ @"darkgrey" : @"#A9A9A9FF",
+ @"darkgreen" : @"#006400FF",
+ @"darkkhaki" : @"#BDB76BFF",
+ @"darkmagenta" : @"#8B008BFF",
+ @"darkolivegreen" : @"#556B2FFF",
+ @"darkorange" : @"#FF8C00FF",
+ @"darkorchid" : @"#9932CCFF",
+ @"darkred" : @"#8B0000FF",
+ @"darksalmon" : @"#E9967AFF",
+ @"darkseagreen" : @"#8FBC8FFF",
+ @"darkslateblue" : @"#483D8BFF",
+ @"darkslategray" : @"#2F4F4FFF",
+ @"darkslategrey" : @"#2F4F4FFF",
+ @"darkturquoise" : @"#00CED1FF",
+ @"darkviolet" : @"#9400D3FF",
+ @"deeppink" : @"#FF1493FF",
+ @"deepskyblue" : @"#00BFFFFF",
+ @"dimgray" : @"#696969FF",
+ @"dimgrey" : @"#696969FF",
+ @"dodgerblue" : @"#1E90FFFF",
+ @"firebrick" : @"#B22222FF",
+ @"floralwhite" : @"#FFFAF0FF",
+ @"forestgreen" : @"#228B22FF",
+ @"fuchsia" : @"#FF00FFFF",
+ @"gainsboro" : @"#DCDCDCFF",
+ @"ghostwhite" : @"#F8F8FFFF",
+ @"gold" : @"#FFD700FF",
+ @"goldenrod" : @"#DAA520FF",
+ @"gray" : @"#808080FF",
+ @"grey" : @"#808080FF",
+ @"green" : @"#008000FF",
+ @"greenyellow" : @"#ADFF2FFF",
+ @"honeydew" : @"#F0FFF0FF",
+ @"hotpink" : @"#FF69B4FF",
+ @"indianred" : @"#CD5C5CFF",
+ @"indigo" : @"#4B0082FF",
+ @"ivory" : @"#FFFFF0FF",
+ @"khaki" : @"#F0E68CFF",
+ @"lavender" : @"#E6E6FAFF",
+ @"lavenderblush" : @"#FFF0F5FF",
+ @"lawngreen" : @"#7CFC00FF",
+ @"lemonchiffon" : @"#FFFACDFF",
+ @"lightblue" : @"#ADD8E6FF",
+ @"lightcoral" : @"#F08080FF",
+ @"lightcyan" : @"#E0FFFFFF",
+ @"lightgoldenrodyellow" : @"#FAFAD2FF",
+ @"lightgray" : @"#D3D3D3FF",
+ @"lightgrey" : @"#D3D3D3FF",
+ @"lightgreen" : @"#90EE90FF",
+ @"lightpink" : @"#FFB6C1FF",
+ @"lightsalmon" : @"#FFA07AFF",
+ @"lightseagreen" : @"#20B2AAFF",
+ @"lightskyblue" : @"#87CEFAFF",
+ @"lightslategray" : @"#778899FF",
+ @"lightslategrey" : @"#778899FF",
+ @"lightsteelblue" : @"#B0C4DEFF",
+ @"lightyellow" : @"#FFFFE0FF",
+ @"lime" : @"#00FF00FF",
+ @"limegreen" : @"#32CD32FF",
+ @"linen" : @"#FAF0E6FF",
+ @"magenta" : @"#FF00FFFF",
+ @"maroon" : @"#800000FF",
+ @"mediumaquamarine" : @"#66CDAAFF",
+ @"mediumblue" : @"#0000CDFF",
+ @"mediumorchid" : @"#BA55D3FF",
+ @"mediumpurple" : @"#9370D8FF",
+ @"mediumseagreen" : @"#3CB371FF",
+ @"mediumslateblue" : @"#7B68EEFF",
+ @"mediumspringgreen" : @"#00FA9AFF",
+ @"mediumturquoise" : @"#48D1CCFF",
+ @"mediumvioletred" : @"#C71585FF",
+ @"midnightblue" : @"#191970FF",
+ @"mintcream" : @"#F5FFFAFF",
+ @"mistyrose" : @"#FFE4E1FF",
+ @"moccasin" : @"#FFE4B5FF",
+ @"navajowhite" : @"#FFDEADFF",
+ @"navy" : @"#000080FF",
+ @"oldlace" : @"#FDF5E6FF",
+ @"olive" : @"#808000FF",
+ @"olivedrab" : @"#6B8E23FF",
+ @"orange" : @"#FFA500FF",
+ @"orangered" : @"#FF4500FF",
+ @"orchid" : @"#DA70D6FF",
+ @"palegoldenrod" : @"#EEE8AAFF",
+ @"palegreen" : @"#98FB98FF",
+ @"paleturquoise" : @"#AFEEEEFF",
+ @"palevioletred" : @"#D87093FF",
+ @"papayawhip" : @"#FFEFD5FF",
+ @"peachpuff" : @"#FFDAB9FF",
+ @"peru" : @"#CD853FFF",
+ @"pink" : @"#FFC0CBFF",
+ @"plum" : @"#DDA0DDFF",
+ @"powderblue" : @"#B0E0E6FF",
+ @"purple" : @"#800080FF",
+ @"rebeccapurple" : @"#663399FF",
+ @"red" : @"#FF0000FF",
+ @"rosybrown" : @"#BC8F8FFF",
+ @"royalblue" : @"#4169E1FF",
+ @"saddlebrown" : @"#8B4513FF",
+ @"salmon" : @"#FA8072FF",
+ @"sandybrown" : @"#F4A460FF",
+ @"seagreen" : @"#2E8B57FF",
+ @"seashell" : @"#FFF5EEFF",
+ @"sienna" : @"#A0522DFF",
+ @"silver" : @"#C0C0C0FF",
+ @"skyblue" : @"#87CEEBFF",
+ @"slateblue" : @"#6A5ACDFF",
+ @"slategray" : @"#708090FF",
+ @"slategrey" : @"#708090FF",
+ @"snow" : @"#FFFAFAFF",
+ @"springgreen" : @"#00FF7FFF",
+ @"steelblue" : @"#4682B4FF",
+ @"tan" : @"#D2B48CFF",
+ @"teal" : @"#008080FF",
+ @"thistle" : @"#D8BFD8FF",
+ @"tomato" : @"#FF6347FF",
+ @"turquoise" : @"#40E0D0FF",
+ @"violet" : @"#EE82EEFF",
+ @"wheat" : @"#F5DEB3FF",
+ @"white" : @"#FFFFFFFF",
+ @"whitesmoke" : @"#F5F5F5FF",
+ @"yellow" : @"#FFFF00FF",
+ @"yellowgreen" : @"#9ACD32FF"
+ };
+ });
+ return namedColorHexes;
+}
+
@implementation UIColor (ColorExtension)
- (BOOL)isEqualToColor:(UIColor *)otherColor {
CGColorSpaceRef colorSpaceRGB = CGColorSpaceCreateDeviceRGB();
@@ -45,4 +203,127 @@ - (UIColor *)colorWithResolvedAlpha:(CGFloat)newAlpha {
}
return self;
}
+
+// Returns a CSS hex color string.
+// Opaque colors produce 6-digit form (#RRGGBB); semi-transparent produce
+// 8-digit form (#RRGGBBAA). Returns @"" if the color cannot be expressed
+// in RGB.
+- (NSString *)hexString {
+ CGFloat red = 0.0;
+ CGFloat green = 0.0;
+ CGFloat blue = 0.0;
+ CGFloat alpha = 0.0;
+
+ if (![self getRed:&red green:&green blue:&blue alpha:&alpha])
+ return @"";
+
+ int r = (int)(red * 255.0 + 0.5);
+ int g = (int)(green * 255.0 + 0.5);
+ int b = (int)(blue * 255.0 + 0.5);
+ int a = (int)(alpha * 255.0 + 0.5);
+
+ if (a == 255)
+ return [NSString stringWithFormat:@"#%02X%02X%02X", r, g, b];
+ return [NSString stringWithFormat:@"#%02X%02X%02X%02X", r, g, b, a];
+}
+
+// Converts a CSS color string (Hex, RGB, RGBA, or Named) into a UIColor.
++ (UIColor *_Nullable)colorFromCSSString:(NSString *_Nullable)cssString {
+ if (cssString.length == 0)
+ return nil;
+
+ // Trim whitespace and force lowercase for easier matching
+ NSString *str =
+ [cssString
+ stringByTrimmingCharactersInSet:[NSCharacterSet
+ whitespaceAndNewlineCharacterSet]]
+ .lowercaseString;
+
+ // Handle Hex (#FFF, #FFFFFF, #FFFFFFFF)
+ if ([str hasPrefix:@"#"]) {
+ str = [str substringFromIndex:1];
+ NSUInteger len = str.length;
+
+ unsigned int value = 0;
+ NSScanner *scanner = [NSScanner scannerWithString:str];
+ if (![scanner scanHexInt:&value])
+ return nil;
+
+ CGFloat r, g, b, a = 1.0;
+
+ if (len == 3) {
+ r = ((value >> 8) & 0xF) / 15.0;
+ g = ((value >> 4) & 0xF) / 15.0;
+ b = (value & 0xF) / 15.0;
+ } else if (len == 6) {
+ r = ((value >> 16) & 0xFF) / 255.0;
+ g = ((value >> 8) & 0xFF) / 255.0;
+ b = (value & 0xFF) / 255.0;
+ } else if (len == 8) {
+ r = ((value >> 24) & 0xFF) / 255.0;
+ g = ((value >> 16) & 0xFF) / 255.0;
+ b = ((value >> 8) & 0xFF) / 255.0;
+ a = (value & 0xFF) / 255.0;
+ } else {
+ return nil; // Invalid hex length
+ }
+
+ return [UIColor colorWithRed:r green:g blue:b alpha:a];
+ }
+
+ // Handle rgb() and rgba()
+ if ([str hasPrefix:@"rgb"]) {
+ NSScanner *scanner = [NSScanner scannerWithString:str];
+
+ // Scan up to and including the opening parenthesis
+ [scanner scanUpToString:@"(" intoString:NULL];
+ if (![scanner scanString:@"(" intoString:NULL])
+ return nil;
+
+ float r = 0, g = 0, b = 0, a = 1.0;
+
+ // Scan Red, then require a comma
+ if (![scanner scanFloat:&r])
+ return nil;
+ if (![scanner scanString:@"," intoString:NULL])
+ return nil;
+
+ // Scan Green, then require a comma
+ if (![scanner scanFloat:&g])
+ return nil;
+ if (![scanner scanString:@"," intoString:NULL])
+ return nil;
+
+ // Scan Blue (comma not required yet, might be alpha or closing parenthesis)
+ if (![scanner scanFloat:&b])
+ return nil;
+
+ // Check if there is a 4th parameter (Alpha)
+ if ([scanner scanString:@"," intoString:NULL]) {
+ if (![scanner scanFloat:&a])
+ return nil;
+ }
+
+ // Require the closing parenthesis to guarantee the string wasn't malformed
+ // or cut off
+ if (![scanner scanString:@")" intoString:NULL])
+ return nil;
+
+ return [UIColor colorWithRed:r / 255.0
+ green:g / 255.0
+ blue:b / 255.0
+ alpha:a];
+ }
+
+ // Handle Named Colors
+ NSString *hexForName = getNamedHexColors()[str];
+ if (hexForName) {
+ // We found a match! Pass the 8-digit hex string right back into this very
+ // method to reuse the Hex parsing logic.
+ return [self colorFromCSSString:hexForName];
+ }
+
+ return nil;
+}
+
@end
diff --git a/ios/htmlParser/HtmlParser.mm b/ios/htmlParser/HtmlParser.mm
index f710a7411..13cfc65dc 100644
--- a/ios/htmlParser/HtmlParser.mm
+++ b/ios/htmlParser/HtmlParser.mm
@@ -1,7 +1,9 @@
#import "HtmlParser.h"
#import "config/EnrichedConfig.h"
+#import "extensions/ColorExtension.h"
#import "extensions/StringExtension.h"
#import "interfaces/AlignmentEntry.h"
+#import "interfaces/CustomStyleData.h"
#import "interfaces/ImageData.h"
#import "interfaces/LinkData.h"
#import "interfaces/MentionParams.h"
@@ -42,9 +44,10 @@ + (BOOL)isBlockTag:(NSString *)tagName {
* you MUST add it to the `textTags` set below.
*/
+ (NSString *)stripExtraWhiteSpacesAndNewlines:(NSString *)html {
- NSSet *textTags = [NSSet setWithObjects:@"p", @"h1", @"h2", @"h3", @"h4",
- @"h5", @"h6", @"li", @"b", @"a", @"s",
- @"mention", @"code", @"u", @"i", nil];
+ NSSet *textTags =
+ [NSSet setWithObjects:@"p", @"h1", @"h2", @"h3", @"h4", @"h5", @"h6",
+ @"li", @"b", @"a", @"s", @"mention", @"code", @"u",
+ @"i", @"span", nil];
NSMutableString *output = [NSMutableString stringWithCapacity:html.length];
NSMutableString *currentTagBuffer = [NSMutableString string];
@@ -834,9 +837,15 @@ + (NSArray *_Nonnull)getTextAndStylesFromHtml:(NSString *_Nonnull)fixedHtml
[styleArr addObject:@([BlockQuoteStyle getType])];
} else if ([tagName isEqualToString:@"codeblock"]) {
[styleArr addObject:@([CodeBlockStyle getType])];
+ } else if ([tagName isEqualToString:@"span"]) {
+ CustomStyleData *data = [self parseCustomStyleDataFromSpanParams:params];
+ if (data == nil || data.isEmpty) {
+ continue;
+ }
+ [styleArr addObject:@([CustomStyle getType])];
+ stylePair.styleValue = data;
} else {
- // some other external tags like span just don't get put into the
- // processed styles
+ // some other external tags don't get put into the processed styles
continue;
}
@@ -866,6 +875,7 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range
BOOL inCodeBlock = NO;
BOOL inCheckboxList = NO;
unichar lastCharacter = 0;
+ CustomStyleData *lastCustomStyleData = nil;
for (int i = 0; i < text.length; i++) {
NSRange currentRange = NSMakeRange(offset + i, 1);
@@ -1014,6 +1024,7 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range
// clear the previous styles
previousActiveStyles = [[NSSet alloc] init];
+ lastCustomStyleData = nil;
// next character opens new paragraph
newLine = YES;
@@ -1175,6 +1186,36 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range
}
}
+ // Force close+reopen if CustomStyle is continuously active but its data
+ // changed, so adjacent runs with different styles produce separate
+ // tags instead of being merged into one.
+ NSNumber *customType = @([CustomStyle getType]);
+ if (![endedStyles member:customType] &&
+ [currentActiveStyles member:customType] &&
+ [previousActiveStyles member:customType]) {
+ CustomStyle *customStyleObj =
+ (CustomStyle *)host.stylesDict[customType];
+ CustomStyleData *currentData =
+ [customStyleObj getStoredCustomStyleDataAt:currentRange.location];
+ if (![currentData isEqual:lastCustomStyleData]) {
+ [fixedEndedStyles addObject:customType];
+ [stylesToBeReAdded addObject:customType];
+
+ // Inner styles (e.g. bold) must also close before the span ends and
+ // reopen inside the new span, otherwise tags cross span boundaries.
+ for (NSNumber *activeStyle in currentActiveStyles) {
+ if ([activeStyle isEqualToNumber:customType] ||
+ [activeStyle isEqualToNumber:@([ImageStyle getType])]) {
+ continue;
+ }
+ if ([activeStyle integerValue] > [customType integerValue]) {
+ [fixedEndedStyles addObject:activeStyle];
+ [stylesToBeReAdded addObject:activeStyle];
+ }
+ }
+ }
+ }
+
// they are sorted in a descending order
NSArray *sortedEndedStyles = [fixedEndedStyles
sortedArrayUsingDescriptors:@[ [NSSortDescriptor
@@ -1220,6 +1261,11 @@ + (NSString *)parseToHtmlFromRange:(NSRange)range
// append the letter and escape it if needed
[result appendString:[NSString stringByEscapingHtml:currentCharacterStr]];
+ // track CustomStyleData for the next character's data-change check
+ lastCustomStyleData =
+ [(CustomStyle *)host.stylesDict[@([CustomStyle getType])]
+ getStoredCustomStyleDataAt:currentRange.location];
+
// save current styles for next character's checks
previousActiveStyles = currentActiveStyles;
}
@@ -1440,6 +1486,50 @@ + (NSString *)tagContentForStyle:(NSNumber *)style
[style isEqualToNumber:@([CodeBlockStyle getType])]) {
// blockquotes and codeblock use tags the same way lists use
return [NSString stringWithFormat:@"p%@", cssStyleString];
+ } else if ([style isEqualToNumber:@([CustomStyle getType])]) {
+ if (openingTag) {
+ CustomStyle *customStyle =
+ (CustomStyle *)host.stylesDict[@([CustomStyle getType])];
+ if (customStyle != nil) {
+ CustomStyleData *data =
+ [customStyle getStoredCustomStyleDataAt:location];
+ if (data != nil && !data.isEmpty) {
+ NSMutableString *cssProps = [NSMutableString string];
+ NSString *fg = [[data foregroundColor] hexString];
+ NSString *bg = [[data backgroundColor] hexString];
+ if (fg.length > 0) {
+ [cssProps appendFormat:@"color: %@;", fg];
+ }
+ if (bg.length > 0) {
+ if (cssProps.length > 0)
+ [cssProps appendString:@" "];
+ [cssProps appendFormat:@"background-color: %@;", bg];
+ }
+ if (data.fontSize != nil) {
+ if (cssProps.length > 0)
+ [cssProps appendString:@" "];
+ [cssProps
+ appendFormat:@"font-size: %gpx;", [data.fontSize doubleValue]];
+ }
+ if (data.fontFamily.length > 0) {
+ if (cssProps.length > 0)
+ [cssProps appendString:@" "];
+ if ([data.fontFamily rangeOfString:@" "].location != NSNotFound) {
+ // font family contains spaces, wrap in single quotes
+ [cssProps appendFormat:@"font-family: '%@';", data.fontFamily];
+ } else {
+ [cssProps appendFormat:@"font-family: %@;", data.fontFamily];
+ }
+ }
+ if (cssProps.length > 0) {
+ return [NSString stringWithFormat:@"span style=\"%@\"", cssProps];
+ }
+ }
+ }
+ return @"span";
+ } else {
+ return @"span";
+ }
}
return @"";
}
@@ -1464,6 +1554,119 @@ + (NSString *)prepareCssStyleString:(NSInteger)location
return @"";
}
++ (CustomStyleData *_Nullable)parseCustomStyleDataFromSpanParams:
+ (NSString *)params {
+ static NSRegularExpression *styleAttrRegex;
+ static NSRegularExpression *fgRegex;
+ static NSRegularExpression *bgRegex;
+ static NSRegularExpression *fontSizeRegex;
+ static NSRegularExpression *fontFamilyRegex;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ styleAttrRegex = [NSRegularExpression
+ regularExpressionWithPattern:@"style\\s*=\\s*[\"']([^\"']*)[\"']"
+ options:NSRegularExpressionCaseInsensitive
+ error:nil];
+ // Captures everything after "color:" until a semicolon or end of string
+ fgRegex = [NSRegularExpression
+ regularExpressionWithPattern:@"(?:^|;)\\s*color\\s*:\\s*([^;]+)"
+ options:NSRegularExpressionCaseInsensitive
+ error:nil];
+
+ // Captures everything after "background-color:" until a semicolon or end of
+ // string
+ bgRegex = [NSRegularExpression
+ regularExpressionWithPattern:@"background-color\\s*:\\s*([^;]+)"
+ options:NSRegularExpressionCaseInsensitive
+ error:nil];
+
+ fontSizeRegex = [NSRegularExpression
+ regularExpressionWithPattern:
+ @"font-size\\s*:\\s*([0-9.]+)(?:\\s*px)?(?=\\s*;|\\s*$)"
+ options:NSRegularExpressionCaseInsensitive
+ error:nil];
+
+ fontFamilyRegex = [NSRegularExpression
+ regularExpressionWithPattern:@"font-family\\s*:\\s*([^;]+)"
+ options:NSRegularExpressionCaseInsensitive
+ error:nil];
+ });
+
+ NSTextCheckingResult *attrMatch =
+ [styleAttrRegex firstMatchInString:params
+ options:0
+ range:NSMakeRange(0, params.length)];
+ if (!attrMatch)
+ return nil;
+
+ NSString *css = [params substringWithRange:[attrMatch rangeAtIndex:1]];
+ CustomStyleData *data = [[CustomStyleData alloc] init];
+
+ NSTextCheckingResult *fgMatch =
+ [fgRegex firstMatchInString:css
+ options:0
+ range:NSMakeRange(0, css.length)];
+ if (fgMatch) {
+ data.foregroundColor = [UIColor
+ colorFromCSSString:[css substringWithRange:[fgMatch rangeAtIndex:1]]];
+ }
+
+ NSTextCheckingResult *bgMatch =
+ [bgRegex firstMatchInString:css
+ options:0
+ range:NSMakeRange(0, css.length)];
+ if (bgMatch) {
+ data.backgroundColor = [UIColor
+ colorFromCSSString:[css substringWithRange:[bgMatch rangeAtIndex:1]]];
+ }
+
+ NSTextCheckingResult *fontSizeMatch =
+ [fontSizeRegex firstMatchInString:css
+ options:0
+ range:NSMakeRange(0, css.length)];
+ if (fontSizeMatch) {
+ NSString *fontSizeString =
+ [css substringWithRange:[fontSizeMatch rangeAtIndex:1]];
+ double parsedFontSize = [fontSizeString doubleValue];
+ if (parsedFontSize > 0) {
+ data.fontSize = @(parsedFontSize);
+ }
+ }
+
+ NSTextCheckingResult *fontFamilyMatch =
+ [fontFamilyRegex firstMatchInString:css
+ options:0
+ range:NSMakeRange(0, css.length)];
+ if (fontFamilyMatch) {
+ NSString *fontFamilyString = [[css
+ substringWithRange:[fontFamilyMatch rangeAtIndex:1]]
+ stringByTrimmingCharactersInSet:[NSCharacterSet
+ whitespaceAndNewlineCharacterSet]];
+
+ // If font family contains a comma, use the first part e.g. "Nunito,
+ // sans-serif" -> "Nunito"
+ NSRange commaRange = [fontFamilyString rangeOfString:@","];
+ if (commaRange.location != NSNotFound) {
+ fontFamilyString =
+ [[fontFamilyString substringToIndex:commaRange.location]
+ stringByTrimmingCharactersInSet:
+ [NSCharacterSet whitespaceAndNewlineCharacterSet]];
+ }
+ if (([fontFamilyString hasPrefix:@"'"] &&
+ [fontFamilyString hasSuffix:@"'"]) ||
+ ([fontFamilyString hasPrefix:@"\""] &&
+ [fontFamilyString hasSuffix:@"\""])) {
+ fontFamilyString = [fontFamilyString
+ substringWithRange:NSMakeRange(1, fontFamilyString.length - 2)];
+ }
+ if (fontFamilyString.length > 0) {
+ data.fontFamily = fontFamilyString;
+ }
+ }
+
+ return data.isEmpty ? nil : data;
+}
+
+ (void)checkForAlignments:(NSArray *)tagData
plainText:(NSMutableString *)plainText
foundAlignments:(NSMutableArray *)foundAlignments
diff --git a/ios/inputHtmlParser/InputHtmlParser.mm b/ios/inputHtmlParser/InputHtmlParser.mm
index 8dec7819e..18011cd92 100644
--- a/ios/inputHtmlParser/InputHtmlParser.mm
+++ b/ios/inputHtmlParser/InputHtmlParser.mm
@@ -182,6 +182,12 @@ - (void)applyProcessedStyles:(NSArray *)processedStyles
}
}
}
+ } else if ([styleType isEqualToNumber:@([CustomStyle getType])]) {
+ CustomStyle *customStyle = (CustomStyle *)baseStyle;
+ [customStyle setCustomStyleData:stylePair.styleValue
+ range:styleRange
+ withTyping:shouldAddTypingAttr
+ withDirtyRange:YES];
} else {
[baseStyle add:styleRange
withTyping:shouldAddTypingAttr
diff --git a/ios/interfaces/CustomStyleData.h b/ios/interfaces/CustomStyleData.h
new file mode 100644
index 000000000..c6ac30e40
--- /dev/null
+++ b/ios/interfaces/CustomStyleData.h
@@ -0,0 +1,17 @@
+#pragma once
+#import
+
+@interface CustomStyleData : NSObject
+
+@property(nonatomic, strong, nullable) UIColor *foregroundColor;
+@property(nonatomic, strong, nullable) UIColor *backgroundColor;
+@property(nonatomic, strong, nullable) NSNumber *fontSize;
+@property(nonatomic, copy, nullable) NSString *fontFamily;
+
+- (BOOL)isEmpty;
+
+// Applies a partial update from a dict. A key absent from the dict leaves the
+// field unchanged; NSNull value clears it.
+- (void)mergeFromDict:(NSDictionary *)dict;
+
+@end
diff --git a/ios/interfaces/CustomStyleData.mm b/ios/interfaces/CustomStyleData.mm
new file mode 100644
index 000000000..cfdd0dc81
--- /dev/null
+++ b/ios/interfaces/CustomStyleData.mm
@@ -0,0 +1,69 @@
+#import "CustomStyleData.h"
+
+@implementation CustomStyleData
+
+- (BOOL)isEmpty {
+ return _foregroundColor == nil && _backgroundColor == nil &&
+ _fontSize == nil && _fontFamily == nil;
+}
+
+- (void)mergeFromDict:(NSDictionary *)dict {
+ id fgVal = dict[@"foregroundColor"];
+ if (fgVal != nil) {
+ self.foregroundColor =
+ [fgVal isKindOfClass:[UIColor class]] ? (UIColor *)fgVal : nil;
+ }
+ id bgVal = dict[@"backgroundColor"];
+ if (bgVal != nil) {
+ self.backgroundColor =
+ [bgVal isKindOfClass:[UIColor class]] ? (UIColor *)bgVal : nil;
+ }
+ id fsVal = dict[@"fontSize"];
+ if (fsVal != nil) {
+ if ([fsVal isKindOfClass:[NSNumber class]] &&
+ [(NSNumber *)fsVal doubleValue] > 0) {
+ self.fontSize = (NSNumber *)fsVal;
+ } else {
+ self.fontSize = nil;
+ }
+ }
+ id ffVal = dict[@"fontFamily"];
+ if (ffVal != nil) {
+ self.fontFamily =
+ [ffVal isKindOfClass:[NSString class]] ? (NSString *)ffVal : nil;
+ }
+}
+
+- (BOOL)isEqual:(id)object {
+ if (self == object)
+ return YES;
+ if (![object isKindOfClass:[CustomStyleData class]])
+ return NO;
+ CustomStyleData *other = (CustomStyleData *)object;
+ BOOL fgEqual = (_foregroundColor == other.foregroundColor) ||
+ [_foregroundColor isEqual:other.foregroundColor];
+ BOOL bgEqual = (_backgroundColor == other.backgroundColor) ||
+ [_backgroundColor isEqual:other.backgroundColor];
+ BOOL fsEqual = (_fontSize == other.fontSize) ||
+ (_fontSize != nil && other.fontSize != nil &&
+ [_fontSize isEqualToNumber:other.fontSize]);
+ BOOL ffEqual = (_fontFamily == other.fontFamily) ||
+ [_fontFamily isEqualToString:other.fontFamily];
+ return fgEqual && bgEqual && fsEqual && ffEqual;
+}
+
+- (NSUInteger)hash {
+ return [_foregroundColor hash] ^ [_backgroundColor hash] ^ [_fontSize hash] ^
+ [_fontFamily hash];
+}
+
+- (id)copyWithZone:(NSZone *)zone {
+ CustomStyleData *copy = [[CustomStyleData allocWithZone:zone] init];
+ copy.foregroundColor = self.foregroundColor;
+ copy.backgroundColor = self.backgroundColor;
+ copy.fontSize = self.fontSize;
+ copy.fontFamily = self.fontFamily;
+ return copy;
+}
+
+@end
diff --git a/ios/interfaces/EnrichedTextStyleHeaders.h b/ios/interfaces/EnrichedTextStyleHeaders.h
index 008f0fce4..6d1a73af8 100644
--- a/ios/interfaces/EnrichedTextStyleHeaders.h
+++ b/ios/interfaces/EnrichedTextStyleHeaders.h
@@ -34,6 +34,9 @@
@interface EnrichedTextH6Style : H6Style
@end
+@interface EnrichedTextCustomStyle : CustomStyle
+@end
+
@interface EnrichedTextBlockQuoteStyle : BlockQuoteStyle
@end
diff --git a/ios/interfaces/StyleBase.mm b/ios/interfaces/StyleBase.mm
index 35b501556..d6056123c 100644
--- a/ios/interfaces/StyleBase.mm
+++ b/ios/interfaces/StyleBase.mm
@@ -45,7 +45,9 @@ - (BOOL)appliesStylingToTyping {
return NO;
}
-// determines the order in which the styles are applied
+// Determines the order in which the styles are applied.
+// 0 = paragraph, 1 = custom, 2 = inline (default), 3 = italic.
+// Styles are applied in ascending priority order so inline styles win.
- (NSInteger)stylePriority {
return [self isParagraph] ? 0 : 2;
}
diff --git a/ios/interfaces/StyleHeaders.h b/ios/interfaces/StyleHeaders.h
index aca5d4a3c..c664429f0 100644
--- a/ios/interfaces/StyleHeaders.h
+++ b/ios/interfaces/StyleHeaders.h
@@ -1,4 +1,5 @@
#pragma once
+#import "CustomStyleData.h"
#import "ImageData.h"
#import "LinkData.h"
#import "MentionParams.h"
@@ -6,6 +7,16 @@
@class EnrichedConfig;
+@interface CustomStyle : StyleBase
+- (void)applyStyleFromDict:(NSDictionary *)dict selectedRange:(NSRange)range;
+- (void)setCustomStyleData:(CustomStyleData *)data
+ range:(NSRange)range
+ withTyping:(BOOL)withTyping
+ withDirtyRange:(BOOL)withDirtyRange;
+- (CustomStyleData *_Nullable)getCustomStyleDataInRange:(NSRange)range;
+- (CustomStyleData *_Nullable)getStoredCustomStyleDataAt:(NSUInteger)location;
+@end
+
@interface BoldStyle : StyleBase
@end
diff --git a/ios/interfaces/StyleTypeEnum.h b/ios/interfaces/StyleTypeEnum.h
index 701d79eaf..a5c163fc8 100644
--- a/ios/interfaces/StyleTypeEnum.h
+++ b/ios/interfaces/StyleTypeEnum.h
@@ -15,6 +15,7 @@ typedef NS_ENUM(NSInteger, StyleType) {
H4,
H5,
H6,
+ Custom,
Link,
Mention,
Image,
diff --git a/ios/styles/CustomStyle.mm b/ios/styles/CustomStyle.mm
new file mode 100644
index 000000000..1ba7e4099
--- /dev/null
+++ b/ios/styles/CustomStyle.mm
@@ -0,0 +1,329 @@
+#import "EnrichedTextInputView.h"
+#import "extensions/ColorExtension.h"
+#import "extensions/FontExtension.h"
+#import "interfaces/CustomStyleData.h"
+#import "interfaces/StyleHeaders.h"
+#import "utils/RangeUtils.h"
+#import
+
+static NSString *const CustomStyleAttributeName = @"EnrichedCustomStyle";
+
+@implementation CustomStyle
+
++ (StyleType)getType {
+ return Custom;
+}
+
+- (NSString *)getKey {
+ return CustomStyleAttributeName;
+}
+
+- (BOOL)isParagraph {
+ return NO;
+}
+
+- (NSInteger)stylePriority {
+ return 1;
+}
+
+- (BOOL)isFontFamilyAvailable:(NSString *)family {
+ return [UIFont fontNamesForFamilyName:family].count > 0 ||
+ [UIFont fontWithName:family size:UIFont.systemFontSize] != nil;
+}
+
+- (UIFont *)resolveFont:(UIFont *)font forData:(CustomStyleData *)data {
+ UIFont *resolvedFont = font ?: [self.host.config primaryFont];
+ UIFont *originalFont = resolvedFont;
+
+ if (data.fontFamily.length > 0) {
+ // if the font family isn't available, we stick to the current font
+ if ([self isFontFamilyAvailable:data.fontFamily]) {
+ resolvedFont = [RCTFont updateFont:resolvedFont
+ withFamily:data.fontFamily
+ size:0
+ weight:nullptr
+ style:nullptr
+ variant:nullptr
+ scaleMultiplier:1];
+ resolvedFont = [resolvedFont withFontTraits:originalFont];
+ } else {
+ NSLog(@"[EnrichedTextInput] Font family '%@' not found, keeping the "
+ @"current font.",
+ data.fontFamily);
+ }
+ }
+
+ if (data.fontSize != nil) {
+ CGFloat pointSize =
+ [self.host.config scaledFontSizeForValue:[data.fontSize doubleValue]];
+ resolvedFont = [resolvedFont setSize:pointSize];
+ }
+
+ return resolvedFont;
+}
+
+- (BOOL)appliesStylingToTyping {
+ CustomStyleData *data =
+ [self getCustomStyleDataAt:self.host.textView.selectedRange.location];
+ return data.fontSize != nil || data.fontFamily.length > 0;
+}
+
+- (void)applyStylingToTypingAttrs:(NSMutableDictionary *)attributes {
+ CustomStyleData *data =
+ [self getCustomStyleDataAt:self.host.textView.selectedRange.location];
+ if (data.fontSize == nil && data.fontFamily.length == 0)
+ return;
+
+ UIFont *currentFont = attributes[NSFontAttributeName];
+ attributes[NSFontAttributeName] = [self resolveFont:currentFont forData:data];
+}
+
+- (BOOL)styleCondition:(id)value range:(NSRange)range {
+ if (![value isKindOfClass:[CustomStyleData class]])
+ return NO;
+ return ![(CustomStyleData *)value isEmpty];
+}
+
+- (void)applyStyling:(NSRange)range {
+ if (range.length == 0)
+ return;
+
+ NSUInteger storageLength = self.host.textView.textStorage.length;
+ if (storageLength == 0)
+ return;
+
+ NSRange safeRange = NSMakeRange(
+ range.location, MIN(range.length, storageLength - range.location));
+
+ // Enumerate each sub-range that carries its own CustomStyleData so that
+ // characters with different data values each get the correct visual attrs.
+ [self.host.textView.textStorage
+ enumerateAttribute:CustomStyleAttributeName
+ inRange:safeRange
+ options:0
+ usingBlock:^(id value, NSRange subRange, BOOL *stop) {
+ if (![value isKindOfClass:[CustomStyleData class]])
+ return;
+ CustomStyleData *data = (CustomStyleData *)value;
+ if (data.isEmpty)
+ return;
+
+ NSMutableDictionary *attrs = [NSMutableDictionary dictionary];
+ if (data.foregroundColor != nil) {
+ attrs[NSForegroundColorAttributeName] = data.foregroundColor;
+ attrs[NSUnderlineColorAttributeName] = data.foregroundColor;
+ attrs[NSStrikethroughColorAttributeName] =
+ data.foregroundColor;
+ }
+ if (data.backgroundColor != nil) {
+ attrs[NSBackgroundColorAttributeName] =
+ [data.backgroundColor colorWithResolvedAlpha];
+ }
+
+ BOOL hasFontOverride =
+ data.fontSize != nil || data.fontFamily.length > 0;
+
+ // Skip newline characters so background color doesn't bleed.
+ NSArray *nonNewlineRanges =
+ [RangeUtils getNonNewlineRangesIn:self.host.textView
+ range:subRange];
+ for (NSValue *rangeVal in nonNewlineRanges) {
+ NSRange applyRange = [rangeVal rangeValue];
+ if (attrs.count > 0) {
+ [self.host.textView.textStorage addAttributes:attrs
+ range:applyRange];
+ }
+ if (hasFontOverride) {
+ [self.host.textView.textStorage
+ enumerateAttribute:NSFontAttributeName
+ inRange:applyRange
+ options:0
+ usingBlock:^(id _Nullable value,
+ NSRange fontRange,
+ BOOL *_Nonnull stop) {
+ UIFont *font = (UIFont *)value;
+ UIFont *newFont = [self resolveFont:font
+ forData:data];
+ [self.host.textView.textStorage
+ addAttribute:NSFontAttributeName
+ value:newFont
+ range:fontRange];
+ }];
+ }
+ }
+ }];
+}
+
+- (void)reapplyFromStylePair:(StylePair *)pair {
+ NSRange range = [pair.rangeValue rangeValue];
+ CustomStyleData *data = (CustomStyleData *)pair.styleValue;
+ if (data == nil || data.isEmpty)
+ return;
+ [self.host.textView.textStorage addAttribute:CustomStyleAttributeName
+ value:data
+ range:range];
+}
+
+- (AttributeEntry *)getEntryIfPresent:(NSRange)range {
+ CustomStyleData *data = [self getCustomStyleDataInRange:range];
+ if (data == nil || data.isEmpty)
+ return nullptr;
+
+ AttributeEntry *entry = [[AttributeEntry alloc] init];
+ entry.key = CustomStyleAttributeName;
+ entry.value = data;
+ return entry;
+}
+
+// MARK: - Public non-standard methods
+
+- (void)setCustomStyleData:(CustomStyleData *)data
+ range:(NSRange)range
+ withTyping:(BOOL)withTyping
+ withDirtyRange:(BOOL)withDirtyRange {
+ if (range.length > 0) {
+ if (data == nil || data.isEmpty) {
+ [self remove:range withDirtyRange:withDirtyRange];
+ return;
+ }
+ [self.host.textView.textStorage addAttribute:CustomStyleAttributeName
+ value:data
+ range:range];
+ if (withDirtyRange) {
+ [self.host.attributesManager addDirtyRange:range];
+ }
+ }
+
+ if (withTyping) {
+ if (data == nil || data.isEmpty) {
+ [self removeTyping];
+ } else {
+ NSMutableDictionary *newTypingAttrs =
+ [self.host.textView.typingAttributes mutableCopy];
+ newTypingAttrs[CustomStyleAttributeName] = data;
+ self.host.textView.typingAttributes = newTypingAttrs;
+ }
+ }
+}
+
+- (CustomStyleData *_Nullable)getCustomStyleDataAt:(NSUInteger)location {
+ NSRange selectedRange = self.host.textView.selectedRange;
+ if (self.host.textView.isEditable && selectedRange.length == 0 &&
+ selectedRange.location == location) {
+ id typingValue =
+ self.host.textView.typingAttributes[CustomStyleAttributeName];
+ if ([typingValue isKindOfClass:[CustomStyleData class]])
+ return (CustomStyleData *)typingValue;
+ return nil;
+ }
+
+ return [self getStoredCustomStyleDataAt:location];
+}
+
+- (CustomStyleData *_Nullable)getCustomStyleDataInRange:(NSRange)range {
+ if (range.length == 0)
+ return [self getCustomStyleDataAt:range.location];
+
+ NSMutableSet *foregroundColors = [NSMutableSet set];
+ NSMutableSet *backgroundColors = [NSMutableSet set];
+ NSMutableSet *fontSizes = [NSMutableSet set];
+ NSMutableSet *fontFamilies = [NSMutableSet set];
+
+ [self.host.textView.textStorage
+ enumerateAttribute:CustomStyleAttributeName
+ inRange:range
+ options:0
+ usingBlock:^(id value, NSRange subRange, BOOL *stop) {
+ CustomStyleData *data =
+ [value isKindOfClass:[CustomStyleData class]]
+ ? (CustomStyleData *)value
+ : nil;
+ [foregroundColors
+ addObject:data.foregroundColor ?: (id)[NSNull null]];
+ [backgroundColors
+ addObject:data.backgroundColor ?: (id)[NSNull null]];
+ [fontSizes addObject:data.fontSize ?: (id)[NSNull null]];
+ [fontFamilies addObject:data.fontFamily ?: (id)[NSNull null]];
+ }];
+
+ CustomStyleData *data = [[CustomStyleData alloc] init];
+ data.foregroundColor = [self getSingleValueFromSet:foregroundColors];
+ data.backgroundColor = [self getSingleValueFromSet:backgroundColors];
+ data.fontSize = [self getSingleValueFromSet:fontSizes];
+ data.fontFamily = [self getSingleValueFromSet:fontFamilies];
+ if (data.isEmpty)
+ return nil;
+
+ return data;
+}
+
+// Reads CustomStyleData directly from textStorage, bypassing typingAttributes.
+- (CustomStyleData *_Nullable)getStoredCustomStyleDataAt:(NSUInteger)location {
+ NSUInteger length = self.host.textView.textStorage.length;
+ if (length == 0)
+ return nil;
+ NSUInteger searchLocation = (location >= length) ? length - 1 : location;
+ id value = [self.host.textView.textStorage attribute:CustomStyleAttributeName
+ atIndex:searchLocation
+ longestEffectiveRange:nil
+ inRange:NSMakeRange(0, length)];
+ if (![value isKindOfClass:[CustomStyleData class]])
+ return nil;
+ return (CustomStyleData *)value;
+}
+
+- (void)applyStyleFromDict:(NSDictionary *)dict selectedRange:(NSRange)range {
+ BOOL withTyping = range.length == 0;
+
+ if (!withTyping) {
+ // Enumerate each existing sub-range and merge the partial update into its
+ // own data so per-character differences (e.g. fg color on some chars) are
+ // preserved when only one field (e.g. bg color) is being changed.
+ NSUInteger storageLength = self.host.textView.textStorage.length;
+ if (storageLength == 0)
+ return;
+
+ NSRange safeRange = NSMakeRange(
+ range.location, MIN(range.length, storageLength - range.location));
+
+ [self.host.textView.textStorage
+ enumerateAttribute:CustomStyleAttributeName
+ inRange:safeRange
+ options:0
+ usingBlock:^(id value, NSRange subRange, BOOL *stop) {
+ CustomStyleData *existing =
+ [value isKindOfClass:[CustomStyleData class]]
+ ? (CustomStyleData *)value
+ : nil;
+ CustomStyleData *merged =
+ existing != nil ? [existing copy]
+ : [[CustomStyleData alloc] init];
+ [merged mergeFromDict:dict];
+ [self setCustomStyleData:merged
+ range:subRange
+ withTyping:NO
+ withDirtyRange:YES];
+ }];
+ } else {
+ // Cursor only: merge into current data and update typing attributes.
+ CustomStyleData *existing = [self getCustomStyleDataAt:range.location];
+ CustomStyleData *merged =
+ existing != nil ? [existing copy] : [[CustomStyleData alloc] init];
+ [merged mergeFromDict:dict];
+ [self setCustomStyleData:merged
+ range:range
+ withTyping:YES
+ withDirtyRange:NO];
+ [self.host.attributesManager
+ didRemoveTypingAttribute:CustomStyleAttributeName];
+ }
+}
+
+- (id _Nullable)getSingleValueFromSet:(NSSet *)values {
+ if (values.count != 1)
+ return nil;
+ id value = values.anyObject;
+ return value == [NSNull null] ? nil : value;
+}
+
+@end
diff --git a/ios/styles/EnrichedTextStyles.mm b/ios/styles/EnrichedTextStyles.mm
index b7a1e40b8..8837c6744 100644
--- a/ios/styles/EnrichedTextStyles.mm
+++ b/ios/styles/EnrichedTextStyles.mm
@@ -33,6 +33,9 @@ @implementation EnrichedTextH5Style
@implementation EnrichedTextH6Style
@end
+@implementation EnrichedTextCustomStyle
+@end
+
@implementation EnrichedTextBlockQuoteStyle
@end
diff --git a/ios/textHtmlParser/TextHtmlParser.mm b/ios/textHtmlParser/TextHtmlParser.mm
index 679cb3dc7..d8287bec4 100644
--- a/ios/textHtmlParser/TextHtmlParser.mm
+++ b/ios/textHtmlParser/TextHtmlParser.mm
@@ -124,6 +124,12 @@ - (void)applyProcessedStyles:(NSArray *_Nonnull)processedStyles {
}
}
}
+ } else if ([styleType isEqualToNumber:@([CustomStyle getType])]) {
+ CustomStyle *customStyle = (CustomStyle *)style;
+ [customStyle setCustomStyleData:stylePair.styleValue
+ range:styleRange
+ withTyping:NO
+ withDirtyRange:NO];
} else {
[style add:styleRange withTyping:NO withDirtyRange:NO];
}
@@ -154,7 +160,7 @@ - (void)applyProcessedStyles:(NSArray *_Nonnull)processedStyles {
}
}
- // Respect the styling priority
+ // Respect the inline styling priority
NSArray *sortedInlineApply =
[pendingInlineApply sortedArrayBySortKey:^NSInteger(NSArray *entry) {
return [((StyleBase *)entry[0]) stylePriority];
diff --git a/ios/utils/StyleUtils.mm b/ios/utils/StyleUtils.mm
index 167a32df5..85d069e03 100644
--- a/ios/utils/StyleUtils.mm
+++ b/ios/utils/StyleUtils.mm
@@ -91,7 +91,8 @@ + (NSDictionary *)conflictMap {
@([CheckboxListStyle getType])
],
@([ImageStyle getType]) :
- @[ @([LinkStyle getType]), @([MentionStyle getType]) ]
+ @[ @([LinkStyle getType]), @([MentionStyle getType]) ],
+ @([CustomStyle getType]) : @[]
};
}
@@ -123,23 +124,35 @@ + (NSDictionary *)blockingMap {
@([AlignmentStyle getType]) : @[],
@([BlockQuoteStyle getType]) : @[],
@([CodeBlockStyle getType]) : @[],
- @([ImageStyle getType]) : @[ @([InlineCodeStyle getType]) ]
+ @([ImageStyle getType]) : @[ @([InlineCodeStyle getType]) ],
+ @([CustomStyle getType]) : @[]
};
}
+ (NSDictionary *)stylesDictForHost:(id)host
isInput:(BOOL)isInput {
NSArray *baseClasses = @[
- [BoldStyle class], [ItalicStyle class],
- [UnderlineStyle class], [StrikethroughStyle class],
- [InlineCodeStyle class], [LinkStyle class],
- [MentionStyle class], [H1Style class],
- [H2Style class], [H3Style class],
- [H4Style class], [H5Style class],
- [H6Style class], [UnorderedListStyle class],
- [OrderedListStyle class], [CheckboxListStyle class],
- [AlignmentStyle class], [BlockQuoteStyle class],
- [CodeBlockStyle class], [ImageStyle class]
+ [BoldStyle class],
+ [ItalicStyle class],
+ [UnderlineStyle class],
+ [StrikethroughStyle class],
+ [InlineCodeStyle class],
+ [LinkStyle class],
+ [MentionStyle class],
+ [H1Style class],
+ [H2Style class],
+ [H3Style class],
+ [H4Style class],
+ [H5Style class],
+ [H6Style class],
+ [CustomStyle class],
+ [UnorderedListStyle class],
+ [OrderedListStyle class],
+ [CheckboxListStyle class],
+ [AlignmentStyle class],
+ [BlockQuoteStyle class],
+ [CodeBlockStyle class],
+ [ImageStyle class]
];
NSArray *viewerClasses = @[
@@ -156,6 +169,7 @@ + (NSDictionary *)stylesDictForHost:(id)host
[EnrichedTextH4Style class],
[EnrichedTextH5Style class],
[EnrichedTextH6Style class],
+ [EnrichedTextCustomStyle class],
[EnrichedTextUnorderedListStyle class],
[EnrichedTextOrderedListStyle class],
[EnrichedTextCheckboxListStyle class],
diff --git a/src/native/EnrichedTextInput.tsx b/src/native/EnrichedTextInput.tsx
index cfe12104c..67817c1c9 100644
--- a/src/native/EnrichedTextInput.tsx
+++ b/src/native/EnrichedTextInput.tsx
@@ -14,13 +14,15 @@ import EnrichedTextInputNativeComponent, {
type OnMentionDetectedInternal,
type OnRequestHtmlResultEvent,
} from '../spec/EnrichedTextInputNativeComponent';
-import type {
- HostComponent,
- HostInstance,
- MeasureInWindowOnSuccessCallback,
- MeasureLayoutOnSuccessCallback,
- MeasureOnSuccessCallback,
- NativeSyntheticEvent,
+import {
+ processColor,
+ type ColorValue,
+ type HostComponent,
+ type HostInstance,
+ type MeasureInWindowOnSuccessCallback,
+ type MeasureLayoutOnSuccessCallback,
+ type MeasureOnSuccessCallback,
+ type NativeSyntheticEvent,
} from 'react-native';
import { normalizeHtmlStyle } from '../utils/normalizeHtmlStyle';
import { toNativeRegexConfig } from '../utils/regexParser';
@@ -39,6 +41,19 @@ const warnMentionIndicators = (indicator: string) => {
);
};
+const getSafeColorInt = (
+ color: ColorValue | null | undefined
+): number | null => {
+ if (color == null) return null;
+
+ const processed = processColor(color);
+ if (typeof processed === 'number') {
+ return processed;
+ }
+
+ return null;
+};
+
type ComponentType = ComponentRef>;
type HtmlRequest = {
@@ -279,6 +294,32 @@ export const EnrichedTextInput = ({
) => {
Commands.setTextAlignment(nullthrows(nativeRef.current), alignment);
},
+ setStyle: (customStyle: {
+ foregroundColor?: ColorValue | null;
+ backgroundColor?: ColorValue | null;
+ fontSize?: number | null;
+ fontFamily?: string | null;
+ }) => {
+ const payload: {
+ foregroundColor?: number | null;
+ backgroundColor?: number | null;
+ fontSize?: number | null;
+ fontFamily?: string | null;
+ } = {};
+ if (customStyle.foregroundColor !== undefined) {
+ payload.foregroundColor = getSafeColorInt(customStyle.foregroundColor);
+ }
+ if (customStyle.backgroundColor !== undefined) {
+ payload.backgroundColor = getSafeColorInt(customStyle.backgroundColor);
+ }
+ if (customStyle.fontSize !== undefined) {
+ payload.fontSize = customStyle.fontSize;
+ }
+ if (customStyle.fontFamily !== undefined) {
+ payload.fontFamily = customStyle.fontFamily;
+ }
+ Commands.setStyle(nullthrows(nativeRef.current), JSON.stringify(payload));
+ },
}));
const handleMentionEvent = (e: NativeSyntheticEvent) => {
diff --git a/src/spec/EnrichedTextInputNativeComponent.ts b/src/spec/EnrichedTextInputNativeComponent.ts
index c5d2e61e3..0f90daaf9 100644
--- a/src/spec/EnrichedTextInputNativeComponent.ts
+++ b/src/spec/EnrichedTextInputNativeComponent.ts
@@ -122,6 +122,12 @@ export interface OnChangeStateEvent {
isBlocking: boolean;
};
alignment: string;
+ customStyle: {
+ foregroundColor: string;
+ backgroundColor: string;
+ fontSize: CodegenTypes.Float;
+ fontFamily: string;
+ };
}
export interface OnLinkDetected {
@@ -278,6 +284,12 @@ export interface OnContextMenuItemPressEvent {
isBlocking: boolean;
};
alignment: string;
+ customStyle: {
+ foregroundColor: string;
+ backgroundColor: string;
+ fontSize: CodegenTypes.Float;
+ fontFamily: string;
+ };
};
}
@@ -482,6 +494,10 @@ interface NativeCommands {
viewRef: React.ElementRef,
alignment: string
) => void;
+ setStyle: (
+ viewRef: React.ElementRef,
+ styleJSON: string
+ ) => void;
}
export const Commands: NativeCommands = codegenNativeCommands({
@@ -516,6 +532,7 @@ export const Commands: NativeCommands = codegenNativeCommands({
'addMention',
'requestHTML',
'setTextAlignment',
+ 'setStyle',
],
});
diff --git a/src/types.ts b/src/types.ts
index d478b123c..fbe606487 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -389,6 +389,12 @@ export interface OnChangeStateEvent {
isBlocking: boolean;
};
alignment: string;
+ customStyle: {
+ foregroundColor: string;
+ backgroundColor: string;
+ fontSize: number;
+ fontFamily: string;
+ };
}
export interface OnLinkDetected {
@@ -582,6 +588,22 @@ export interface EnrichedTextInputInstance {
setTextAlignment: (
alignment: 'left' | 'center' | 'right' | 'justify' | 'auto'
) => void;
+
+ /**
+ * Sets the custom style (text and background colors, font size, font family) for
+ * the current text selection.
+ *
+ * @param customStyle - An object specifying the styles to apply.
+ * Provide `foregroundColor` to change the text color, `backgroundColor` to change
+ * the text highlight color, `fontSize` to change the font size, and `fontFamily`
+ * to change the font family. Passing `null` for a property will remove that specific style.
+ */
+ setStyle: (customStyle: {
+ foregroundColor?: ColorValue | null;
+ backgroundColor?: ColorValue | null;
+ fontSize?: number | null;
+ fontFamily?: string | null;
+ }) => void;
}
export interface ContextMenuItem {
diff --git a/src/web/EnrichedText.css b/src/web/EnrichedText.css
index 32181dad2..b0acfb1bb 100644
--- a/src/web/EnrichedText.css
+++ b/src/web/EnrichedText.css
@@ -52,6 +52,9 @@
padding: 0;
line-height: inherit;
vertical-align: baseline;
+ box-shadow:
+ 0 -1px 0 var(--et-code-bg-color),
+ 0 0 0 var(--et-code-bg-color);
}
.eti-editor h1,
diff --git a/src/web/EnrichedText.tsx b/src/web/EnrichedText.tsx
index 1786c5a4f..cb864a52d 100644
--- a/src/web/EnrichedText.tsx
+++ b/src/web/EnrichedText.tsx
@@ -1,5 +1,6 @@
import {
memo,
+ useCallback,
useImperativeHandle,
useMemo,
useRef,
@@ -27,6 +28,8 @@ import { adaptWebToNativeEvent } from './nativeMappers/adaptWebToNativeEvent';
import { useStableRef } from './utils/useStableRef';
import { assertBrowserEnvironment } from './utils/assertBrowserEnvironment';
import { useOrderedListMarkerWidth } from './htmlExtensions/useOrderedListMarkerWidth';
+import { enrichedStyleToCSSVariables } from './styleConversion/enrichedStyleToCSSVariables';
+import { useBaseFontFamily } from './utils/useBaseFontFamily';
export const EnrichedText = memo(
({
@@ -87,12 +90,19 @@ export const EnrichedText = memo(
[style]
);
+ const getContainerElement = useCallback(() => containerRef.current, []);
+ const baseFontFamily = useBaseFontFamily(
+ getContainerElement,
+ textStyle.fontFamily
+ );
+
const cssVars = useMemo(
() => ({
...enrichedTextHtmlStyleToCSSVariables(resolvedHtmlStyle),
...INLINE_IMAGE_CSS_VARIABLES,
+ ...enrichedStyleToCSSVariables({ fontFamily: baseFontFamily }),
}),
- [resolvedHtmlStyle]
+ [resolvedHtmlStyle, baseFontFamily]
);
const themingStyle = useMemo(
diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx
index 1002b647c..c166059a1 100644
--- a/src/web/EnrichedTextInput.tsx
+++ b/src/web/EnrichedTextInput.tsx
@@ -42,6 +42,7 @@ import { ENRICHED_TEXT_INPUT_DEFAULT_PROPS } from '../utils/EnrichedTextInputDef
import { enrichedInputStyleToCSSProperties } from './styleConversion/enrichedInputStyleToCSSProperties';
import { enrichedInputThemingToCSSProperties } from './styleConversion/enrichedThemingToCSSProperties';
import { buildMentionRulesCSS } from './styleConversion/buildMentionRulesCSS';
+import { enrichedStyleToCSSVariables } from './styleConversion/enrichedStyleToCSSVariables';
import {
htmlStyleToCSSVariables,
mergeWithDefaultHtmlStyle,
@@ -63,6 +64,7 @@ import { EnrichedOrderedList } from './formats/EnrichedOrderedList';
import { EnrichedCheckboxItem } from './formats/EnrichedCheckboxItem';
import { EnrichedCheckboxList } from './formats/EnrichedCheckboxList';
import { EnrichedTextAlign } from './formats/EnrichedTextAlign';
+import { EnrichedCustomStyle } from './formats/EnrichedCustomStyle';
import { StripBoldInStyledHeadingsPlugin } from './pmPlugins/StripBoldInStyledHeadingsPlugin';
import { StrictMarksPlugin } from './pmPlugins/StrictMarksPlugin';
import { MergeAdjacentSameKindBlocksPlugin } from './pmPlugins/MergeAdjacentSameKindBlocksPlugin';
@@ -92,6 +94,8 @@ import {
import { assertBrowserEnvironment } from './utils/assertBrowserEnvironment';
import { runSafelyInEditor } from './utils/runSafelyInEditor';
import { useLinkPress } from './htmlExtensions/useLinkPress';
+import { useBaseFontFamily } from './utils/useBaseFontFamily';
+import { normalizeColorValue } from './normalization/colorNormalizer';
function runFocused(
editor: Editor,
@@ -234,6 +238,7 @@ export const EnrichedTextInput = ({
EnrichedOrderedList,
EnrichedCheckboxList,
EnrichedTextAlign,
+ EnrichedCustomStyle,
StripMarksInCodeBlockPlugin,
StripMarksOnImagePlugin,
StripBoldInStyledHeadingsPlugin.configure({
@@ -433,6 +438,24 @@ export const EnrichedTextInput = ({
runFocused(editor, (c) => c.setTextAlign(alignment));
}
},
+ setStyle: (customStyle) => {
+ runFocused(editor, (c) =>
+ c.setCustomStyle({
+ ...('foregroundColor' in customStyle && {
+ foregroundColor: normalizeColorValue(customStyle.foregroundColor),
+ }),
+ ...('backgroundColor' in customStyle && {
+ backgroundColor: normalizeColorValue(customStyle.backgroundColor),
+ }),
+ ...('fontSize' in customStyle && {
+ fontSize: customStyle.fontSize,
+ }),
+ ...('fontFamily' in customStyle && {
+ fontFamily: customStyle.fontFamily,
+ }),
+ })
+ );
+ },
}),
[editor, mentionIndicatorsRef, useHtmlNormalizerRef, sanitizationConfigRef]
);
@@ -442,9 +465,18 @@ export const EnrichedTextInput = ({
[scrollEnabled, style]
);
+ const getEditorElement = useCallback(() => editor.view.dom, [editor]);
+ const baseFontFamily = useBaseFontFamily(
+ getEditorElement,
+ editorStyle.fontFamily
+ );
+
const cssVars = useMemo(
- () => htmlStyleToCSSVariables(resolvedHtmlStyle),
- [resolvedHtmlStyle]
+ () => ({
+ ...htmlStyleToCSSVariables(resolvedHtmlStyle),
+ ...enrichedStyleToCSSVariables({ fontFamily: baseFontFamily }),
+ }),
+ [resolvedHtmlStyle, baseFontFamily]
);
const themingStyle = useMemo(
diff --git a/src/web/formats/EnrichedCustomStyle.ts b/src/web/formats/EnrichedCustomStyle.ts
new file mode 100644
index 000000000..b64ddbcf3
--- /dev/null
+++ b/src/web/formats/EnrichedCustomStyle.ts
@@ -0,0 +1,256 @@
+import { Mark } from '@tiptap/core';
+import type { Attrs } from '@tiptap/pm/model';
+import { withBaseFontFamilyFallback } from '../styleConversion/enrichedStyleToCSSVariables';
+
+type CustomStyleAttrs = {
+ foregroundColor?: string | null;
+ backgroundColor?: string | null;
+ fontSize?: number | null;
+ fontFamily?: string | null;
+};
+
+function normalizeFontFamily(value: string | null | undefined): string | null {
+ if (!value) return null;
+
+ let fontFamily = value.trim();
+ const commaIndex = fontFamily.indexOf(',');
+ if (commaIndex !== -1) {
+ fontFamily = fontFamily.slice(0, commaIndex).trim();
+ }
+
+ if (
+ (fontFamily.startsWith("'") && fontFamily.endsWith("'")) ||
+ (fontFamily.startsWith('"') && fontFamily.endsWith('"'))
+ ) {
+ fontFamily = fontFamily.slice(1, -1);
+ }
+
+ return fontFamily.length > 0 ? fontFamily : null;
+}
+
+function resolveFontSize(value: number | null | undefined): number | null {
+ if (value == null || value <= 0) return null;
+ return value;
+}
+
+function parseFontSize(value: string | null | undefined): number | null {
+ if (!value) return null;
+ const trimmed = value.trim();
+ const match = /^([0-9.]+)\s*(?:px)?$/i.exec(trimmed);
+ if (!match) return null;
+ const n = parseFloat(match[1]!);
+ return !Number.isNaN(n) && n > 0 ? n : null;
+}
+
+function buildStyle(attrs: Attrs, withFontFamilyFallback = false): string {
+ const parts: string[] = [];
+ if (attrs.foregroundColor) {
+ parts.push(`color: ${attrs.foregroundColor}`);
+ }
+ if (attrs.backgroundColor) {
+ parts.push(`background-color: ${attrs.backgroundColor}`);
+ }
+ if (attrs.fontSize) {
+ parts.push(`font-size: ${attrs.fontSize}px`);
+ }
+ if (attrs.fontFamily) {
+ const fontFamily = attrs.fontFamily as string;
+ // if the font family contains a space, wrap it in quotes
+ const family = /\s/.test(fontFamily) ? `'${fontFamily}'` : fontFamily;
+ // add a fall-back to the input's inherited font, if needed
+ parts.push(
+ `font-family: ${withFontFamilyFallback ? withBaseFontFamilyFallback(family) : family}`
+ );
+ }
+ return parts.join('; ');
+}
+
+declare module '@tiptap/core' {
+ interface Commands {
+ customStyle: {
+ setCustomStyle: (attrs: CustomStyleAttrs) => ReturnType;
+ };
+ }
+}
+
+export const EnrichedCustomStyle = Mark.create({
+ name: 'customStyle',
+
+ // Priority must be higher than inline marks (code: 1000, mention: 1000) so
+ // the inline marks will override the customStyle.
+ priority: 1001,
+
+ addAttributes() {
+ return {
+ foregroundColor: {
+ default: null,
+ parseHTML: (el: HTMLElement) => el.style.color || null,
+ },
+ backgroundColor: {
+ default: null,
+ parseHTML: (el: HTMLElement) => el.style.backgroundColor || null,
+ },
+ fontSize: {
+ default: null,
+ parseHTML: (el: HTMLElement) => parseFontSize(el.style.fontSize),
+ },
+ fontFamily: {
+ default: null,
+ parseHTML: (el: HTMLElement) =>
+ normalizeFontFamily(el.style.fontFamily),
+ },
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'span',
+ getAttrs: (el: HTMLElement) => {
+ if (
+ !el.style.color &&
+ !el.style.backgroundColor &&
+ !el.style.fontSize &&
+ !el.style.fontFamily
+ ) {
+ return false;
+ }
+ // let addAttributes handle the actual parsing
+ return null;
+ },
+ },
+ ];
+ },
+
+ renderHTML({ mark }) {
+ return ['span', { style: buildStyle(mark.attrs) }, 0];
+ },
+
+ // The editor DOM gets a style tag's font-family definition with a fallback,
+ // whereas renderHTML stays fallback-free since it's what getHTML() serializes.
+ addMarkView() {
+ return ({ mark }) => {
+ const dom = document.createElement('span');
+ dom.setAttribute('style', buildStyle(mark.attrs, true));
+ return { dom, contentDOM: dom };
+ };
+ },
+
+ addKeyboardShortcuts() {
+ return {
+ Backspace: ({ editor }) => {
+ const { state } = editor;
+ const { selection } = state;
+ if (!selection.empty) return false;
+
+ const { $from } = selection;
+ if (!$from.nodeBefore) return false;
+
+ const markType = state.schema.marks.customStyle;
+ const beforeHasMark = markType?.isInSet($from.nodeBefore.marks);
+ const afterHasMark =
+ $from.nodeAfter && markType?.isInSet($from.nodeAfter.marks);
+
+ if (beforeHasMark || !afterHasMark) return false;
+
+ // Cursor sits right at the plain-styled boundary.
+ // Handle deletion ourselves so Chrome's contenteditable
+ // normalization never touches the styled .
+ const tr = state.tr.delete($from.pos - 1, $from.pos);
+ editor.view.dispatch(tr);
+ return true;
+ },
+ };
+ },
+
+ addCommands() {
+ return {
+ setCustomStyle:
+ (attrs) =>
+ ({ state, tr, dispatch }) => {
+ const markType = state.schema.marks.customStyle;
+ if (!markType) return false;
+
+ // Only the fields explicitly present in `attrs` should override.
+ // Everything else must be preserved per existing inline run, so a
+ // selection spanning multiple fonts/colors keeps its differences.
+ const patch: CustomStyleAttrs = {};
+ if ('foregroundColor' in attrs) {
+ patch.foregroundColor = attrs.foregroundColor ?? null;
+ }
+ if ('backgroundColor' in attrs) {
+ patch.backgroundColor = attrs.backgroundColor ?? null;
+ }
+ if ('fontSize' in attrs) {
+ patch.fontSize = resolveFontSize(attrs.fontSize);
+ }
+ if ('fontFamily' in attrs) {
+ patch.fontFamily = normalizeFontFamily(attrs.fontFamily);
+ }
+
+ const mergeAttrs = (
+ existing: Attrs | undefined
+ ): Required => ({
+ foregroundColor: existing?.foregroundColor || null,
+ backgroundColor: existing?.backgroundColor || null,
+ fontSize: resolveFontSize(existing?.fontSize),
+ fontFamily: normalizeFontFamily(existing?.fontFamily),
+ ...patch,
+ });
+
+ const isEmpty = (a: Required) =>
+ !a.foregroundColor &&
+ !a.backgroundColor &&
+ !a.fontSize &&
+ !a.fontFamily;
+
+ const { selection } = state;
+
+ if (selection.empty) {
+ // Cursor only: merge into the stored (typing) mark.
+ const existing = markType.isInSet(
+ state.storedMarks ?? selection.$from.marks()
+ );
+ const merged = mergeAttrs(existing?.attrs);
+ if (dispatch) {
+ if (isEmpty(merged)) {
+ tr.removeStoredMark(markType);
+ } else {
+ tr.addStoredMark(markType.create(merged));
+ }
+ dispatch(tr);
+ }
+ return true;
+ }
+
+ if (dispatch) {
+ selection.ranges.forEach((range) => {
+ const rFrom = range.$from.pos;
+ const rTo = range.$to.pos;
+ state.doc.nodesBetween(rFrom, rTo, (node, pos) => {
+ // Only inline runs carry the mark; block nodes are skipped so
+ // per-run attributes are preserved. ProseMirror's addMark step
+ // itself skips any inline node that disallows the mark type.
+ if (!node.isInline) {
+ return;
+ }
+ const start = Math.max(pos, rFrom);
+ const end = Math.min(pos + node.nodeSize, rTo);
+ if (start >= end) return;
+
+ const existing = markType.isInSet(node.marks);
+ const merged = mergeAttrs(existing?.attrs);
+
+ tr.removeMark(start, end, markType);
+ if (!isEmpty(merged)) {
+ tr.addMark(start, end, markType.create(merged));
+ }
+ });
+ });
+ dispatch(tr);
+ }
+ return true;
+ },
+ };
+ },
+});
diff --git a/src/web/normalization/colorNormalizer.ts b/src/web/normalization/colorNormalizer.ts
new file mode 100644
index 000000000..7aceb6b61
--- /dev/null
+++ b/src/web/normalization/colorNormalizer.ts
@@ -0,0 +1,238 @@
+import type { ColorValue } from 'react-native';
+
+const CSS_NAMED_COLORS: Record = {
+ aliceblue: '#F0F8FFFF',
+ antiquewhite: '#FAEBD7FF',
+ aqua: '#00FFFFFF',
+ aquamarine: '#7FFFD4FF',
+ azure: '#F0FFFFFF',
+ beige: '#F5F5DCFF',
+ bisque: '#FFE4C4FF',
+ black: '#000000FF',
+ blanchedalmond: '#FFEBCDFF',
+ blue: '#0000FFFF',
+ blueviolet: '#8A2BE2FF',
+ brown: '#A52A2AFF',
+ burlywood: '#DEB887FF',
+ cadetblue: '#5F9EA0FF',
+ chartreuse: '#7FFF00FF',
+ chocolate: '#D2691EFF',
+ coral: '#FF7F50FF',
+ cornflowerblue: '#6495EDFF',
+ cornsilk: '#FFF8DCFF',
+ crimson: '#DC143CFF',
+ cyan: '#00FFFFFF',
+ darkblue: '#00008BFF',
+ darkcyan: '#008B8BFF',
+ darkgoldenrod: '#B8860BFF',
+ darkgray: '#A9A9A9FF',
+ darkgrey: '#A9A9A9FF',
+ darkgreen: '#006400FF',
+ darkkhaki: '#BDB76BFF',
+ darkmagenta: '#8B008BFF',
+ darkolivegreen: '#556B2FFF',
+ darkorange: '#FF8C00FF',
+ darkorchid: '#9932CCFF',
+ darkred: '#8B0000FF',
+ darksalmon: '#E9967AFF',
+ darkseagreen: '#8FBC8FFF',
+ darkslateblue: '#483D8BFF',
+ darkslategray: '#2F4F4FFF',
+ darkslategrey: '#2F4F4FFF',
+ darkturquoise: '#00CED1FF',
+ darkviolet: '#9400D3FF',
+ deeppink: '#FF1493FF',
+ deepskyblue: '#00BFFFFF',
+ dimgray: '#696969FF',
+ dimgrey: '#696969FF',
+ dodgerblue: '#1E90FFFF',
+ firebrick: '#B22222FF',
+ floralwhite: '#FFFAF0FF',
+ forestgreen: '#228B22FF',
+ fuchsia: '#FF00FFFF',
+ gainsboro: '#DCDCDCFF',
+ ghostwhite: '#F8F8FFFF',
+ gold: '#FFD700FF',
+ goldenrod: '#DAA520FF',
+ gray: '#808080FF',
+ grey: '#808080FF',
+ green: '#008000FF',
+ greenyellow: '#ADFF2FFF',
+ honeydew: '#F0FFF0FF',
+ hotpink: '#FF69B4FF',
+ indianred: '#CD5C5CFF',
+ indigo: '#4B0082FF',
+ ivory: '#FFFFF0FF',
+ khaki: '#F0E68CFF',
+ lavender: '#E6E6FAFF',
+ lavenderblush: '#FFF0F5FF',
+ lawngreen: '#7CFC00FF',
+ lemonchiffon: '#FFFACDFF',
+ lightblue: '#ADD8E6FF',
+ lightcoral: '#F08080FF',
+ lightcyan: '#E0FFFFFF',
+ lightgoldenrodyellow: '#FAFAD2FF',
+ lightgray: '#D3D3D3FF',
+ lightgrey: '#D3D3D3FF',
+ lightgreen: '#90EE90FF',
+ lightpink: '#FFB6C1FF',
+ lightsalmon: '#FFA07AFF',
+ lightseagreen: '#20B2AAFF',
+ lightskyblue: '#87CEFAFF',
+ lightslategray: '#778899FF',
+ lightslategrey: '#778899FF',
+ lightsteelblue: '#B0C4DEFF',
+ lightyellow: '#FFFFE0FF',
+ lime: '#00FF00FF',
+ limegreen: '#32CD32FF',
+ linen: '#FAF0E6FF',
+ magenta: '#FF00FFFF',
+ maroon: '#800000FF',
+ mediumaquamarine: '#66CDAAFF',
+ mediumblue: '#0000CDFF',
+ mediumorchid: '#BA55D3FF',
+ mediumpurple: '#9370D8FF',
+ mediumseagreen: '#3CB371FF',
+ mediumslateblue: '#7B68EEFF',
+ mediumspringgreen: '#00FA9AFF',
+ mediumturquoise: '#48D1CCFF',
+ mediumvioletred: '#C71585FF',
+ midnightblue: '#191970FF',
+ mintcream: '#F5FFFAFF',
+ mistyrose: '#FFE4E1FF',
+ moccasin: '#FFE4B5FF',
+ navajowhite: '#FFDEADFF',
+ navy: '#000080FF',
+ oldlace: '#FDF5E6FF',
+ olive: '#808000FF',
+ olivedrab: '#6B8E23FF',
+ orange: '#FFA500FF',
+ orangered: '#FF4500FF',
+ orchid: '#DA70D6FF',
+ palegoldenrod: '#EEE8AAFF',
+ palegreen: '#98FB98FF',
+ paleturquoise: '#AFEEEEFF',
+ palevioletred: '#D87093FF',
+ papayawhip: '#FFEFD5FF',
+ peachpuff: '#FFDAB9FF',
+ peru: '#CD853FFF',
+ pink: '#FFC0CBFF',
+ plum: '#DDA0DDFF',
+ powderblue: '#B0E0E6FF',
+ purple: '#800080FF',
+ rebeccapurple: '#663399FF',
+ red: '#FF0000FF',
+ rosybrown: '#BC8F8FFF',
+ royalblue: '#4169E1FF',
+ saddlebrown: '#8B4513FF',
+ salmon: '#FA8072FF',
+ sandybrown: '#F4A460FF',
+ seagreen: '#2E8B57FF',
+ seashell: '#FFF5EEFF',
+ sienna: '#A0522DFF',
+ silver: '#C0C0C0FF',
+ skyblue: '#87CEEBFF',
+ slateblue: '#6A5ACDFF',
+ slategray: '#708090FF',
+ slategrey: '#708090FF',
+ snow: '#FFFAFAFF',
+ springgreen: '#00FF7FFF',
+ steelblue: '#4682B4FF',
+ tan: '#D2B48CFF',
+ teal: '#008080FF',
+ thistle: '#D8BFD8FF',
+ tomato: '#FF6347FF',
+ turquoise: '#40E0D0FF',
+ violet: '#EE82EEFF',
+ wheat: '#F5DEB3FF',
+ white: '#FFFFFFFF',
+ whitesmoke: '#F5F5F5FF',
+ yellow: '#FFFF00FF',
+ yellowgreen: '#9ACD32FF',
+};
+
+export function normalizeColorToHex(
+ value: string | null | undefined
+): string | null {
+ if (!value) return null;
+ let str = value.trim().toLowerCase();
+
+ // Handle Named Colors
+ if (CSS_NAMED_COLORS[str] !== undefined) {
+ str = CSS_NAMED_COLORS[str]!.toLowerCase();
+ }
+
+ // Handle Hex (#FFF, #RGBA, #RRGGBB, #RRGGBBAA)
+ if (str.startsWith('#')) {
+ const hex = str.substring(1);
+ let r,
+ g,
+ b,
+ a = 'ff';
+
+ if (hex.length === 3) {
+ r = hex.charAt(0) + hex.charAt(0);
+ g = hex.charAt(1) + hex.charAt(1);
+ b = hex.charAt(2) + hex.charAt(2);
+ } else if (hex.length === 4) {
+ r = hex.charAt(0) + hex.charAt(0);
+ g = hex.charAt(1) + hex.charAt(1);
+ b = hex.charAt(2) + hex.charAt(2);
+ a = hex.charAt(3) + hex.charAt(3);
+ } else if (hex.length === 6) {
+ r = hex.substring(0, 2);
+ g = hex.substring(2, 4);
+ b = hex.substring(4, 6);
+ } else if (hex.length === 8) {
+ r = hex.substring(0, 2);
+ g = hex.substring(2, 4);
+ b = hex.substring(4, 6);
+ a = hex.substring(6, 8);
+ } else {
+ return null;
+ }
+
+ // drop alpha if it's 255 (FF)
+ if (a === 'ff') {
+ return `#${r}${g}${b}`.toUpperCase();
+ }
+ return `#${r}${g}${b}${a}`.toUpperCase();
+ }
+
+ // Handle rgb() and rgba()
+ if (str.startsWith('rgb')) {
+ const match = str.match(
+ /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/
+ );
+ if (match && match[1] && match[2] && match[3]) {
+ const r = Math.round(parseFloat(match[1])).toString(16).padStart(2, '0');
+ const g = Math.round(parseFloat(match[2])).toString(16).padStart(2, '0');
+ const b = Math.round(parseFloat(match[3])).toString(16).padStart(2, '0');
+
+ let a = 'ff';
+ if (match[4] !== undefined) {
+ a = Math.round(parseFloat(match[4]) * 255)
+ .toString(16)
+ .padStart(2, '0');
+ }
+
+ if (a === 'ff') {
+ return `#${r}${g}${b}`.toUpperCase();
+ }
+ return `#${r}${g}${b}${a}`.toUpperCase();
+ }
+ }
+
+ return null;
+}
+
+export function normalizeColorValue(
+ color?: ColorValue | null
+): string | null | undefined {
+ if (color === undefined) return undefined;
+ if (color === null) return null;
+ if (typeof color === 'string') return color;
+
+ // If it's an OpaqueColorValue return null
+ return null;
+}
diff --git a/src/web/normalization/htmlNormalizer.ts b/src/web/normalization/htmlNormalizer.ts
index 35e6ef8a2..257463a07 100644
--- a/src/web/normalization/htmlNormalizer.ts
+++ b/src/web/normalization/htmlNormalizer.ts
@@ -594,10 +594,35 @@ function walkNode(node: Node, out: { buf: string }): void {
// : CSS style → inline tags
if (name === 'span') {
+ const htmlNode = node as HTMLElement;
const s = parseCssStyle(node.getAttribute('style'));
+
+ const fg = htmlNode.style.color;
+ const bg = htmlNode.style.backgroundColor;
+ const fontSize = htmlNode.style.fontSize;
+ const fontFamily = escapeText(htmlNode.style.fontFamily);
+
+ // build the preserved span if colors exist
+ let spanOpen = '';
+ let spanClose = '';
+ const styleParts: string[] = [];
+
+ if (fg) styleParts.push(`color: ${fg}`);
+ if (bg) styleParts.push(`background-color: ${bg}`);
+ if (fontSize) styleParts.push(`font-size: ${fontSize}`);
+ if (fontFamily) styleParts.push(`font-family: ${fontFamily}`);
+
+ if (styleParts.length > 0) {
+ spanOpen = ``;
+ spanClose = '';
+ }
+
+ // output everything in the correct nested order
+ out.buf += spanOpen;
out.buf += emitStylesOpen(s);
walkChildren(node, out);
out.buf += emitStylesClose(s);
+ out.buf += spanClose;
return;
}
diff --git a/src/web/normalization/prepareHtmlForWeb.ts b/src/web/normalization/prepareHtmlForWeb.ts
index 8da6d63e8..29ad25f30 100644
--- a/src/web/normalization/prepareHtmlForWeb.ts
+++ b/src/web/normalization/prepareHtmlForWeb.ts
@@ -1,4 +1,5 @@
import { normalizeHtml } from './htmlNormalizer';
+import { withBaseFontFamilyFallback } from '../styleConversion/enrichedStyleToCSSVariables';
export function prepareHtmlForWeb(
html: string,
@@ -13,6 +14,7 @@ export function prepareHtmlForWeb(
wrapBareLiContentInParagraph(doc);
checkboxHtmlToWeb(doc);
+ addFontFamilyFallback(doc);
return doc.body.innerHTML;
}
@@ -98,3 +100,12 @@ function checkboxHtmlToWeb(doc: Document) {
});
});
}
+
+function addFontFamilyFallback(doc: Document) {
+ doc.querySelectorAll('span[style]').forEach((span) => {
+ const fontFamily = span.style.fontFamily;
+ if (fontFamily) {
+ span.style.fontFamily = withBaseFontFamilyFallback(fontFamily);
+ }
+ });
+}
diff --git a/src/web/normalization/tiptapHtmlNormalizer.ts b/src/web/normalization/tiptapHtmlNormalizer.ts
index cadbda43a..27745d1ef 100644
--- a/src/web/normalization/tiptapHtmlNormalizer.ts
+++ b/src/web/normalization/tiptapHtmlNormalizer.ts
@@ -4,6 +4,7 @@ import {
checkboxHtmlForTiptap,
checkboxHtmlFromTiptap,
} from './checkboxHtmlNormalizer';
+import { normalizeColorToHex } from './colorNormalizer';
import { normalizeHtml } from './htmlNormalizer';
export function prepareHtmlForTiptap(
@@ -48,5 +49,30 @@ export function normalizeHtmlFromTiptap(
return `
`;
});
+ // Find all style="..." attributes in the HTML
+ html = html.replace(/style="([^"]*)"/gi, (_, styleString: string) => {
+ let updatedStyle = styleString;
+
+ // Convert color: to hex
+ updatedStyle = updatedStyle.replace(
+ /(?:^|;)\s*color\s*:\s*([^;]+)/gi,
+ (match, colorValue) => {
+ const hex = normalizeColorToHex(colorValue);
+ return hex ? match.replace(colorValue, hex) : match;
+ }
+ );
+
+ // Convert background-color: to hex
+ updatedStyle = updatedStyle.replace(
+ /(?:^|;)\s*background-color\s*:\s*([^;]+)/gi,
+ (match, bgColorValue) => {
+ const hex = normalizeColorToHex(bgColorValue);
+ return hex ? match.replace(bgColorValue, hex) : match;
+ }
+ );
+
+ return `style="${updatedStyle}"`;
+ });
+
return `${html}`;
}
diff --git a/src/web/pmPlugins/StripMarksInCodeBlockPlugin.ts b/src/web/pmPlugins/StripMarksInCodeBlockPlugin.ts
index 5eee201fb..66836e1a8 100644
--- a/src/web/pmPlugins/StripMarksInCodeBlockPlugin.ts
+++ b/src/web/pmPlugins/StripMarksInCodeBlockPlugin.ts
@@ -18,6 +18,9 @@ export const StripMarksInCodeBlockPlugin = Extension.create({
newState.doc.descendants((node, pos) => {
if (node.type.name === 'codeBlock') {
allMarks.forEach((markType) => {
+ if (markType.name === 'customStyle') {
+ return;
+ }
tr.removeMark(pos + 1, pos + node.nodeSize - 1, markType);
});
return false;
diff --git a/src/web/styleConversion/enrichedStyleToCSSVariables.ts b/src/web/styleConversion/enrichedStyleToCSSVariables.ts
new file mode 100644
index 000000000..d0bea9eec
--- /dev/null
+++ b/src/web/styleConversion/enrichedStyleToCSSVariables.ts
@@ -0,0 +1,18 @@
+import type { CSSProperties } from 'react';
+
+export const ET_STYLE_CSS_VARS = {
+ // The input's base font, used as the custom style font family fallback
+ fontFamily: '--et-font-family',
+} as const;
+
+export function enrichedStyleToCSSVariables(
+ style: Pick
+): CSSProperties {
+ const vars: Record = {};
+ if (style.fontFamily) vars[ET_STYLE_CSS_VARS.fontFamily] = style.fontFamily;
+ return vars as CSSProperties;
+}
+
+export function withBaseFontFamilyFallback(fontFamily: string): string {
+ return `${fontFamily}, var(${ET_STYLE_CSS_VARS.fontFamily}, ${fontFamily})`;
+}
diff --git a/src/web/tiptapWatchers/useOnChangeState.ts b/src/web/tiptapWatchers/useOnChangeState.ts
index cdee4b57b..80f175193 100644
--- a/src/web/tiptapWatchers/useOnChangeState.ts
+++ b/src/web/tiptapWatchers/useOnChangeState.ts
@@ -9,6 +9,7 @@ import {
isFormatBlocked,
} from '../formats/formatRules';
import type { HtmlStyle } from '../../types';
+import { normalizeColorToHex } from '../normalization/colorNormalizer';
export const useOnChangeState = (
editor: Editor | null,
@@ -63,6 +64,8 @@ function buildState(
};
}
+ const customStyle = getCurrentCustomStyleAttributes(editor);
+
return {
bold: inlineFormat('bold', false),
italic: inlineFormat('italic', false),
@@ -99,22 +102,68 @@ function buildState(
isBlocking: isFormatBlocked('image', editor, htmlStyle),
},
alignment: getCurrentAlignment(editor) ?? 'auto',
+ customStyle: {
+ foregroundColor: toHexColor(customStyle.foregroundColor),
+ backgroundColor: toHexColor(customStyle.backgroundColor),
+ fontSize: customStyle.fontSize ?? 0,
+ fontFamily: customStyle.fontFamily ?? '',
+ },
};
}
+function toHexColor(color: string | null | undefined): string {
+ return normalizeColorToHex(color) ?? color ?? '';
+}
+
+function getCurrentCustomStyleAttributes(editor: Editor): Record {
+ const { selection, doc, schema } = editor.state;
+ if (selection.empty) {
+ const marks = editor.state.storedMarks ?? selection.$from.marks();
+ return schema.marks.customStyle?.isInSet(marks)?.attrs ?? {};
+ }
+
+ const foregroundColors = new Set();
+ const backgroundColors = new Set();
+ const fontSizes = new Set();
+ const fontFamilies = new Set();
+
+ doc.nodesBetween(selection.from, selection.to, (node) => {
+ if (!node.isText) return;
+ const attrs = schema.marks.customStyle?.isInSet(node.marks)?.attrs;
+ foregroundColors.add(toHexColor(attrs?.foregroundColor) || null);
+ backgroundColors.add(toHexColor(attrs?.backgroundColor) || null);
+ fontSizes.add(attrs?.fontSize ?? null);
+ fontFamilies.add(attrs?.fontFamily ?? null);
+ });
+
+ return {
+ foregroundColor: getSingleValue(foregroundColors),
+ backgroundColor: getSingleValue(backgroundColors),
+ fontSize: getSingleValue(fontSizes),
+ fontFamily: getSingleValue(fontFamilies),
+ };
+}
+
+function getSingleValue(values: Set): T | null {
+ return values.size === 1 ? ([...values][0] ?? null) : null;
+}
+
function hashState(state: OnChangeStateEvent): string {
- return Object.values(state)
- .map((formatState) => {
- if (typeof formatState === 'string') return formatState;
- return String(
- getFormatHash(
- formatState.isActive,
- formatState.isConflicting,
- formatState.isBlocking
- )
- );
+ const formatEntries = Object.entries(state).filter(
+ ([key]) => key !== 'alignment' && key !== 'customStyle'
+ );
+ const formatHash = formatEntries
+ .map(([, formatState]) => {
+ const s = formatState as {
+ isActive: boolean;
+ isConflicting: boolean;
+ isBlocking: boolean;
+ };
+ return String(getFormatHash(s.isActive, s.isConflicting, s.isBlocking));
})
.join('');
+
+ return `${formatHash}|${state.alignment}|${state.customStyle.foregroundColor}|${state.customStyle.backgroundColor}|${state.customStyle.fontSize}|${state.customStyle.fontFamily}`;
}
function getFormatHash(
diff --git a/src/web/utils/useBaseFontFamily.ts b/src/web/utils/useBaseFontFamily.ts
new file mode 100644
index 000000000..185810a19
--- /dev/null
+++ b/src/web/utils/useBaseFontFamily.ts
@@ -0,0 +1,23 @@
+import { useLayoutEffect, useState } from 'react';
+
+// without a fontFamily in style, the element inherits its font from the DOM
+export const useBaseFontFamily = (
+ getElement: () => HTMLElement | null,
+ fontFamily?: string
+): string | undefined => {
+ const [inheritedFontFamily, setInheritedFontFamily] = useState();
+
+ useLayoutEffect(() => {
+ if (fontFamily) return;
+ try {
+ const element = getElement();
+ if (element) {
+ setInheritedFontFamily(getComputedStyle(element).fontFamily);
+ }
+ } catch {
+ // element isn't mounted yet
+ }
+ }, [getElement, fontFamily]);
+
+ return fontFamily ?? inheritedFontFamily;
+};