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

+
Black text on green
+ + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'custom_style_colors_visual' diff --git a/.maestro/enrichedText/flows/custom_style_fonts_visual.yaml b/.maestro/enrichedText/flows/custom_style_fonts_visual.yaml new file mode 100644 index 000000000..e5c9b62a4 --- /dev/null +++ b/.maestro/enrichedText/flows/custom_style_fonts_visual.yaml @@ -0,0 +1,29 @@ +appId: swmansion.enriched.example +--- +# Validates that custom style fonts 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: > + +

Regular family

+

24px plain

+

Bold family

+

32 Courier

+

Bold 20px Italic family Code bold

+
H5 40px
+

Quote code family

+ + +- 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: '

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: + '
Black on green
', + expected: + '
Black on green
', + }, + { + name: 'color wraps bold mark', + 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

', + '
Black text on green
', + '', + ].join(''); + + await setEditorHtml(page, html); + + const editor = editorLocator(page); + await expect(editor).toHaveScreenshot('custom-style-colors-visual.png'); +}); + +test.describe('custom style colors - toolbar interaction', () => { + test.beforeEach(async ({ page }) => { + await gotoVisualRegression(page); + }); + + test('apply foreground color then type text', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyTextColor(page, '#FF0000'); + await editor.pressSequentially('Red text', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + '

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: + '
Big heading
', + expected: + '
Big heading
', + }, + { + name: 'font size wraps bold mark', + 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

', + '
H5 40px
', + '

Quote mono family

', + '', + ].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( + '

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 implements ContentHandler { @@ -425,6 +475,19 @@ class HtmlToSpannedConverter implements ContentHandler { private static final Pattern CSS_ALIGNMENT_PATTERN = Pattern.compile("text-align\\s*:\\s*(left|center|right)", Pattern.CASE_INSENSITIVE); + private static final Pattern CSS_FG_PATTERN = + Pattern.compile("(?:^|;)\\s*(? void endMention(Editable text, T style, EnrichedSpanFactory void endSpan(Editable text, T style, EnrichedSpanFactory spanFactory) { + CustomStyleMark mark = getLast(text, CustomStyleMark.class); + if (mark == null) return; + setSpanFromMark( + text, + mark, + spanFactory.createCustomStyleSpan(mark.mFg, mark.mBg, mark.mFontSize, mark.mFontFamily)); + } + public void setDocumentLocator(Locator locator) {} public void startDocument() {} @@ -1055,6 +1176,20 @@ public Newline(int numNewlines) { } } + private static class CustomStyleMark { + public final Integer mFg; + public final Integer mBg; + public final Float mFontSize; + public final String mFontFamily; + + public CustomStyleMark(Integer fg, Integer bg, Float fontSize, String fontFamily) { + mFg = fg; + mBg = bg; + mFontSize = fontSize; + mFontFamily = fontFamily; + } + } + private static class Alignment { final String mCssValue; diff --git a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt index 38fb167b1..30c300c7a 100644 --- a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt +++ b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt @@ -5,6 +5,7 @@ import com.swmansion.enriched.common.spans.EnrichedBlockQuoteSpan 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 @@ -80,4 +81,11 @@ interface EnrichedSpanFactory { fun createBlockQuoteSpan(style: T): EnrichedBlockQuoteSpan fun createCodeBlockSpan(style: T): EnrichedCodeBlockSpan + + fun createCustomStyleSpan( + foregroundColor: Int?, + backgroundColor: Int?, + fontSize: Float?, + fontFamily: String?, + ): EnrichedCustomStyleSpan } diff --git a/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedCustomStyleSpan.kt b/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedCustomStyleSpan.kt new file mode 100644 index 000000000..a41cfe0a0 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedCustomStyleSpan.kt @@ -0,0 +1,96 @@ +package com.swmansion.enriched.common.spans + +import android.content.res.AssetManager +import android.graphics.Typeface +import android.text.TextPaint +import android.text.style.MetricAffectingSpan +import android.util.Log +import com.facebook.react.common.ReactConstants +import com.facebook.react.common.assets.ReactFontManager +import com.facebook.react.views.text.ReactTypefaceUtils.applyStyles +import com.swmansion.enriched.common.CustomStyle +import com.swmansion.enriched.common.pixelFromSpOrDp +import com.swmansion.enriched.common.spans.interfaces.EnrichedInlineSpan +import com.swmansion.enriched.common.withOpacity + +open class EnrichedCustomStyleSpan( + private val foregroundColor: Int?, + private val backgroundColor: Int?, + private val fontSizeSp: Float?, + private val fontFamily: String?, + private val assets: AssetManager, + private val allowFontScaling: Boolean, +) : MetricAffectingSpan(), + EnrichedInlineSpan { + private val resolvedFontFamily: String? = + fontFamily?.trim()?.takeIf { it.isNotEmpty() }?.let { family -> + if (isFontFamilyAvailable(family)) { + family + } else { + Log.w(TAG, "Font family '$family' not found, keeping the current font.") + null + } + } + + fun getForegroundColor(): Int? = foregroundColor + + fun getBackgroundColor(): Int? = backgroundColor + + fun getFontSize(): Float? = fontSizeSp + + fun getFontFamily(): String? = fontFamily + + fun toCustomStyle(): CustomStyle = + CustomStyle( + foregroundColor = foregroundColor, + backgroundColor = backgroundColor, + fontSize = fontSizeSp, + fontFamily = fontFamily, + ) + + protected fun getAssets(): AssetManager = assets + + protected fun getAllowFontScaling(): Boolean = allowFontScaling + + override fun updateMeasureState(textPaint: TextPaint) { + applyFontState(textPaint) + } + + override fun updateDrawState(textPaint: TextPaint) { + foregroundColor?.let { textPaint.color = it } + backgroundColor?.let { textPaint.bgColor = withOpacity(it) } + applyFontState(textPaint) + } + + private fun applyFontState(textPaint: TextPaint) { + resolvedFontFamily?.let { family -> + textPaint.typeface = + applyStyles( + textPaint.typeface, + ReactConstants.UNSET, + ReactConstants.UNSET, + family, + assets, + ) + } + fontSizeSp?.takeIf { it > 0f }?.let { size -> + textPaint.textSize = pixelFromSpOrDp(size, allowFontScaling) + } + } + + // ReactFontManager resolves custom fonts in such way, we don't have a way + // of knowing if Typeface.DEFAULT was returned as a fallback to an unavailable + // font family, or because we requested a default font family ourselves. We + // compare desired font to the sans-serif family, which is hardcoded as a default + // one in the Typeface source code + private fun isFontFamilyAvailable(family: String): Boolean = + family == DEFAULT_FAMILY || + ReactFontManager.getInstance().getTypeface(family, Typeface.NORMAL, assets) != Typeface.DEFAULT + + companion object { + private const val TAG = "EnrichedCustomStyleSpan" + + // Mirrors the framework's private Typeface.DEFAULT_FAMILY + private const val DEFAULT_FAMILY = "sans-serif" + } +} diff --git a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt index f61ac812b..4e1dd397b 100644 --- a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt +++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt @@ -1,11 +1,14 @@ package com.swmansion.enriched.text +import android.content.res.AssetManager import com.swmansion.enriched.common.parser.EnrichedSpanFactory +import com.swmansion.enriched.common.spans.EnrichedCustomStyleSpan import com.swmansion.enriched.text.spans.EnrichedTextAlignmentSpan import com.swmansion.enriched.text.spans.EnrichedTextBlockQuoteSpan import com.swmansion.enriched.text.spans.EnrichedTextBoldSpan import com.swmansion.enriched.text.spans.EnrichedTextCheckboxListSpan import com.swmansion.enriched.text.spans.EnrichedTextCodeBlockSpan +import com.swmansion.enriched.text.spans.EnrichedTextCustomStyleSpan import com.swmansion.enriched.text.spans.EnrichedTextH1Span import com.swmansion.enriched.text.spans.EnrichedTextH2Span import com.swmansion.enriched.text.spans.EnrichedTextH3Span @@ -22,7 +25,10 @@ import com.swmansion.enriched.text.spans.EnrichedTextStrikeThroughSpan import com.swmansion.enriched.text.spans.EnrichedTextUnderlineSpan import com.swmansion.enriched.text.spans.EnrichedTextUnorderedListSpan -class EnrichedTextSpanFactory : EnrichedSpanFactory { +class EnrichedTextSpanFactory( + private val assets: AssetManager, + private val allowFontScaling: Boolean, +) : EnrichedSpanFactory { override fun createAlignmentSpan(cssValue: String) = EnrichedTextAlignmentSpan(cssValue) override fun createBoldSpan(style: EnrichedTextStyle) = EnrichedTextBoldSpan(style) @@ -81,4 +87,19 @@ class EnrichedTextSpanFactory : EnrichedSpanFactory { override fun createBlockQuoteSpan(style: EnrichedTextStyle) = EnrichedTextBlockQuoteSpan(style) override fun createCodeBlockSpan(style: EnrichedTextStyle) = EnrichedTextCodeBlockSpan(style) + + override fun createCustomStyleSpan( + foregroundColor: Int?, + backgroundColor: Int?, + fontSize: Float?, + fontFamily: String?, + ): EnrichedCustomStyleSpan = + EnrichedTextCustomStyleSpan( + foregroundColor, + backgroundColor, + fontSize, + fontFamily, + assets, + allowFontScaling, + ) } diff --git a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextStyle.kt b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextStyle.kt index d1ee9b4ff..4f82aca95 100644 --- a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextStyle.kt +++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextStyle.kt @@ -5,9 +5,11 @@ import com.facebook.react.bridge.ColorPropConverter import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.ReadableMap import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight +import com.swmansion.enriched.common.DEFAULT_BACKGROUND_OPACITY import com.swmansion.enriched.common.EnrichedStyle import com.swmansion.enriched.common.MentionStyle import com.swmansion.enriched.common.pixelFromSpOrDp +import com.swmansion.enriched.common.withOpacity import kotlin.math.ceil data class EnrichedTextStyle( @@ -113,10 +115,10 @@ data class EnrichedTextStyle( aUnderline = parseIsUnderline(link), aPressColor = parseColor(context, link, "pressColor"), codeBlockColor = parseColor(context, codeblock, "color"), - codeBlockBackgroundColor = parseColorWithOpacity(context, codeblock, "backgroundColor", 80), + codeBlockBackgroundColor = parseColorWithOpacity(context, codeblock, "backgroundColor"), codeBlockRadius = parseFloat(codeblock, "borderRadius", allowFontScaling), inlineCodeColor = parseColor(context, inlineCode, "color"), - inlineCodeBackgroundColor = parseColorWithOpacity(context, inlineCode, "backgroundColor", 80), + inlineCodeBackgroundColor = parseColorWithOpacity(context, inlineCode, "backgroundColor"), mentionsStyle = parseMentionsStyle(context, mentions), ) } @@ -152,11 +154,10 @@ data class EnrichedTextStyle( context: ReactContext, map: ReadableMap?, key: String, - opacity: Int, + opacity: Int = DEFAULT_BACKGROUND_OPACITY, ): Int { val color = parseColor(context, map, key) - if (Color.alpha(color) != 255) return color - return (color and 0x00FFFFFF) or (opacity.coerceIn(0, 255) shl 24) + return withOpacity(color, opacity) } private fun parseIsUnderline(map: ReadableMap?): Boolean = map?.getString("textDecorationLine") == "underline" @@ -190,10 +191,10 @@ data class EnrichedTextStyle( result[key] = MentionStyle( color = parseColor(context, value, "color"), - backgroundColor = parseColorWithOpacity(context, value, "backgroundColor", 80), + backgroundColor = parseColorWithOpacity(context, value, "backgroundColor"), underline = parseIsUnderline(value), pressColor = parseColor(context, value, "pressColor"), - pressBackgroundColor = parseColorWithOpacity(context, value, "pressBackgroundColor", 80), + pressBackgroundColor = parseColorWithOpacity(context, value, "pressBackgroundColor"), ) } return result diff --git a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt index 357016da3..ad781514b 100644 --- a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt +++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt @@ -48,13 +48,18 @@ class EnrichedTextView : AppCompatTextView { set(value) { if (field == value) return field = value + // Invalidate the spannable factory so that it is recreated with the new allowFontScaling value + cachedSpannableFactory = null fontSizeRaw?.let { setFontSize(it) } htmlStyleMap?.let { setHtmlStyle(it) } applyLineSpacing() } private var enrichedStyle: EnrichedTextStyle? = null - private val spannableFactory = EnrichedTextSpanFactory() + + private var cachedSpannableFactory: EnrichedTextSpanFactory? = null + private val spannableFactory: EnrichedTextSpanFactory + get() = cachedSpannableFactory ?: EnrichedTextSpanFactory(context.assets, allowFontScaling).also { cachedSpannableFactory = it } // We keep the parsedText around so that when an async image finishes loading we can re-call // setText with the same instance and force the TextView to rebuild its layout. diff --git a/android/src/main/java/com/swmansion/enriched/text/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/text/MeasurementStore.kt index f2805659c..d032f8320 100644 --- a/android/src/main/java/com/swmansion/enriched/text/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/text/MeasurementStore.kt @@ -121,7 +121,7 @@ object MeasurementStore { val allowFontScaling = allowFontScalingFromProps(props) val enrichedStyle = EnrichedTextStyle.fromReadableMap(context as ReactContext, fontSize, style, allowFontScaling) - val factory = EnrichedTextSpanFactory() + val factory = EnrichedTextSpanFactory(context.assets, allowFontScaling) val parsed = EnrichedParser.fromHtml(textToParse, enrichedStyle, factory) return parsed.trimEnd('\n') } catch (e: Exception) { diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextCustomStyleSpan.kt b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextCustomStyleSpan.kt new file mode 100644 index 000000000..fdb6566b6 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextCustomStyleSpan.kt @@ -0,0 +1,35 @@ +package com.swmansion.enriched.text.spans + +import android.content.res.AssetManager +import com.swmansion.enriched.common.spans.EnrichedCustomStyleSpan +import com.swmansion.enriched.text.EnrichedTextStyle +import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan + +class EnrichedTextCustomStyleSpan( + foregroundColor: Int?, + backgroundColor: Int?, + fontSize: Float?, + fontFamily: String?, + assets: AssetManager, + allowFontScaling: Boolean, +) : EnrichedCustomStyleSpan( + foregroundColor, + backgroundColor, + fontSize, + fontFamily, + assets, + allowFontScaling, + ), + EnrichedTextSpan { + override val dependsOnHtmlStyle: Boolean = false + + override fun rebuildWithStyle(style: EnrichedTextStyle): EnrichedTextCustomStyleSpan = + EnrichedTextCustomStyleSpan( + getForegroundColor(), + getBackgroundColor(), + getFontSize(), + getFontFamily(), + getAssets(), + getAllowFontScaling(), + ) +} diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt index fadeb6e1b..6a60cce25 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt @@ -1,12 +1,15 @@ package com.swmansion.enriched.textinput +import android.content.res.AssetManager import com.swmansion.enriched.common.parser.EnrichedSpanFactory +import com.swmansion.enriched.common.spans.EnrichedCustomStyleSpan import com.swmansion.enriched.common.spans.EnrichedImageSpan import com.swmansion.enriched.textinput.spans.EnrichedInputAlignmentSpan import com.swmansion.enriched.textinput.spans.EnrichedInputBlockQuoteSpan import com.swmansion.enriched.textinput.spans.EnrichedInputBoldSpan import com.swmansion.enriched.textinput.spans.EnrichedInputCheckboxListSpan import com.swmansion.enriched.textinput.spans.EnrichedInputCodeBlockSpan +import com.swmansion.enriched.textinput.spans.EnrichedInputCustomStyleSpan import com.swmansion.enriched.textinput.spans.EnrichedInputH1Span import com.swmansion.enriched.textinput.spans.EnrichedInputH2Span import com.swmansion.enriched.textinput.spans.EnrichedInputH3Span @@ -24,7 +27,10 @@ import com.swmansion.enriched.textinput.spans.EnrichedInputUnderlineSpan import com.swmansion.enriched.textinput.spans.EnrichedInputUnorderedListSpan import com.swmansion.enriched.textinput.styles.HtmlStyle -class EnrichedTextInputSpannableFactory : EnrichedSpanFactory { +class EnrichedTextInputSpannableFactory( + private val assets: AssetManager, + private val allowFontScaling: Boolean, +) : EnrichedSpanFactory { override fun createAlignmentSpan(cssValue: String) = EnrichedInputAlignmentSpan(cssValue) override fun createBoldSpan(style: HtmlStyle) = EnrichedInputBoldSpan(style) @@ -83,4 +89,19 @@ class EnrichedTextInputSpannableFactory : EnrichedSpanFactory { override fun createBlockQuoteSpan(style: HtmlStyle) = EnrichedInputBlockQuoteSpan(style) override fun createCodeBlockSpan(style: HtmlStyle) = EnrichedInputCodeBlockSpan(style) + + override fun createCustomStyleSpan( + foregroundColor: Int?, + backgroundColor: Int?, + fontSize: Float?, + fontFamily: String?, + ): EnrichedCustomStyleSpan = + EnrichedInputCustomStyleSpan( + foregroundColor, + backgroundColor, + fontSize, + fontFamily, + assets, + allowFontScaling, + ) } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt index e61435f35..b0f403e5b 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -60,6 +60,7 @@ import com.swmansion.enriched.textinput.spans.EnrichedLineHeightSpan import com.swmansion.enriched.textinput.spans.EnrichedSpans import com.swmansion.enriched.textinput.spans.interfaces.EnrichedInputSpan import com.swmansion.enriched.textinput.styles.AlignmentStyles +import com.swmansion.enriched.textinput.styles.CustomStyles import com.swmansion.enriched.textinput.styles.HtmlStyle import com.swmansion.enriched.textinput.styles.InlineStyles import com.swmansion.enriched.textinput.styles.ListStyles @@ -91,6 +92,7 @@ class EnrichedTextInputView : val shortcutsHandler: ShortcutsHandler? = ShortcutsHandler(this) val parametrizedStyles: ParametrizedStyles? = ParametrizedStyles(this) val alignmentStyles: AlignmentStyles? = AlignmentStyles(this) + val customStyles: CustomStyles? = CustomStyles(this) var isDuringTransaction: Boolean = false var isRemovingMany: Boolean = false var scrollEnabled: Boolean = true @@ -98,6 +100,8 @@ class EnrichedTextInputView : set(value) { if (field != value) { field = value + // Invalidate the spannable factory so that it is recreated with the new allowFontScaling value + cachedSpannableFactory = null val raw = fontSizeRaw if (raw != null) { setFontSize(raw) // re-invokes invalidateStyles internally @@ -146,7 +150,13 @@ class EnrichedTextInputView : private var defaultValueDirty: Boolean = false private var inputMethodManager: InputMethodManager? = null - private val spannableFactory = EnrichedTextInputSpannableFactory() + + private var cachedSpannableFactory: EnrichedTextInputSpannableFactory? = null + private val spannableFactory: EnrichedTextInputSpannableFactory + get() = + cachedSpannableFactory + ?: EnrichedTextInputSpannableFactory(context.assets, allowFontScaling).also { cachedSpannableFactory = it } + private var contextMenuItems: List> = emptyList() constructor(context: Context) : super(context) { @@ -1034,6 +1044,15 @@ class EnrichedTextInputView : selection?.validateStyles() } + fun setStyle(styleJSON: String) { + val isValid = verifyStyle(EnrichedSpans.CUSTOM_STYLE) + if (!isValid) return + + runAsATransaction { + customStyles?.setStyle(styleJSON) + } + } + fun requestHTML(requestId: Int) { val html = try { diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt index 8b43f3280..9cf0293da 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -495,6 +495,13 @@ class EnrichedTextInputViewManager : view?.setTextAlignment(alignment) } + override fun setStyle( + view: EnrichedTextInputView?, + styleJSON: String, + ) { + view?.setStyle(styleJSON) + } + override fun measure( context: Context, localData: ReadableMap?, diff --git a/android/src/main/java/com/swmansion/enriched/textinput/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/textinput/MeasurementStore.kt index ea309ef8a..7ca655a45 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/MeasurementStore.kt @@ -116,7 +116,11 @@ object MeasurementStore { try { val htmlStyle = HtmlStyle(defaultView, props.getMap("htmlStyle")) - val factory = EnrichedTextInputSpannableFactory() + val factory = + EnrichedTextInputSpannableFactory( + defaultView.context.assets, + allowFontScalingFromProps(props), + ) val parsed = EnrichedParser.fromHtml(defaultValue, htmlStyle, factory) return parsed.trimEnd('\n') } catch (e: Exception) { diff --git a/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedInputCustomStyleSpan.kt b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedInputCustomStyleSpan.kt new file mode 100644 index 000000000..da78595cb --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedInputCustomStyleSpan.kt @@ -0,0 +1,35 @@ +package com.swmansion.enriched.textinput.spans + +import android.content.res.AssetManager +import com.swmansion.enriched.common.spans.EnrichedCustomStyleSpan +import com.swmansion.enriched.textinput.spans.interfaces.EnrichedInputSpan +import com.swmansion.enriched.textinput.styles.HtmlStyle + +class EnrichedInputCustomStyleSpan( + foregroundColor: Int?, + backgroundColor: Int?, + fontSize: Float?, + fontFamily: String?, + assets: AssetManager, + allowFontScaling: Boolean, +) : EnrichedCustomStyleSpan( + foregroundColor, + backgroundColor, + fontSize, + fontFamily, + assets, + allowFontScaling, + ), + EnrichedInputSpan { + override val dependsOnHtmlStyle: Boolean = false + + override fun rebuildWithStyle(htmlStyle: HtmlStyle): EnrichedInputCustomStyleSpan = + EnrichedInputCustomStyleSpan( + getForegroundColor(), + getBackgroundColor(), + getFontSize(), + getFontFamily(), + getAssets(), + getAllowFontScaling(), + ) +} diff --git a/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedSpans.kt b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedSpans.kt index 0e40e9e8a..d164b7db8 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedSpans.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedSpans.kt @@ -50,6 +50,9 @@ object EnrichedSpans { const val IMAGE = "image" const val MENTION = "mention" + // custom style + const val CUSTOM_STYLE = "custom_style" + val inlineSpans: Map = mapOf( BOLD to BaseSpanConfig(EnrichedInputBoldSpan::class.java), @@ -85,7 +88,12 @@ object EnrichedSpans { MENTION to BaseSpanConfig(EnrichedInputMentionSpan::class.java), ) - val allSpans: Map = inlineSpans + paragraphSpans + listSpans + parametrizedStyles + val customStyles: Map = + mapOf( + CUSTOM_STYLE to BaseSpanConfig(EnrichedInputCustomStyleSpan::class.java), + ) + + val allSpans: Map = inlineSpans + paragraphSpans + listSpans + parametrizedStyles + customStyles fun getMergingConfigForStyle( style: String, @@ -231,6 +239,10 @@ object EnrichedSpans { ) } + CUSTOM_STYLE -> { + StylesMergingConfig() + } + else -> { null } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/CustomStyles.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/CustomStyles.kt new file mode 100644 index 000000000..6782418ff --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/CustomStyles.kt @@ -0,0 +1,233 @@ +package com.swmansion.enriched.textinput.styles + +import android.text.Editable +import android.text.Spannable +import com.swmansion.enriched.common.CustomStyle +import com.swmansion.enriched.common.EnrichedSpanFlags +import com.swmansion.enriched.textinput.EnrichedTextInputView +import com.swmansion.enriched.textinput.spans.EnrichedInputCustomStyleSpan +import org.json.JSONObject + +class CustomStyles( + private val view: EnrichedTextInputView, +) { + fun setStyle(styleJSON: String) { + val selection = view.selection ?: return + val (start, end) = selection.getInlineSelection() + + val json = runCatching { JSONObject(styleJSON) }.getOrNull() ?: return + + val hasFg = json.has("foregroundColor") + val hasBg = json.has("backgroundColor") + val hasFontSize = json.has("fontSize") + val hasFontFamily = json.has("fontFamily") + + val fgColor = if (hasFg && !json.isNull("foregroundColor")) json.getInt("foregroundColor") else null + val bgColor = if (hasBg && !json.isNull("backgroundColor")) json.getInt("backgroundColor") else null + val fontSize = + if (hasFontSize && !json.isNull("fontSize")) { + json.getDouble("fontSize").toFloat().takeIf { it > 0f } + } else { + null + } + val fontFamily = + if (hasFontFamily && !json.isNull("fontFamily")) { + json.getString("fontFamily").trim().takeIf { it.isNotEmpty() } + } else { + null + } + + val styleUpdater: (CustomStyle) -> CustomStyle = { oldStyle -> + CustomStyle( + foregroundColor = if (hasFg) fgColor else oldStyle.foregroundColor, + backgroundColor = if (hasBg) bgColor else oldStyle.backgroundColor, + fontSize = if (hasFontSize) fontSize else oldStyle.fontSize, + fontFamily = if (hasFontFamily) fontFamily else oldStyle.fontFamily, + ) + } + + if (start == end) { + val merged = styleUpdater(view.spanState?.customStyle ?: CustomStyle()) + view.spanState?.setCustomStyle( + merged.foregroundColor, + merged.backgroundColor, + merged.fontSize, + merged.fontFamily, + ) + } else { + applyCustomStyleSpan(view.text as Spannable, start, end, styleUpdater) + view.selection.validateStyles() + } + } + + private fun applyCustomStyleSpan( + spannable: Spannable, + start: Int, + end: Int, + styleUpdater: (CustomStyle) -> CustomStyle, + ) { + val existingSpans = spannable.getSpans(start, end, EnrichedInputCustomStyleSpan::class.java) + val boundaries = mutableSetOf(start, end) + + // Snapshot boundaries and spans before any modifications + val oldSpans = + existingSpans.mapNotNull { span -> + val spanStart = spannable.getSpanStart(span) + val spanEnd = spannable.getSpanEnd(span) + if (spanStart == -1 || spanEnd == -1) null else Triple(span, spanStart, spanEnd) + } + + // Remove old spans, restore outer edges, and collect internal boundaries + for ((span, spanStart, spanEnd) in oldSpans) { + val style = span.toCustomStyle() + + spannable.removeSpan(span) + + if (spanStart < start) setCustomSpan(spannable, spanStart, start, style) + if (spanEnd > end) setCustomSpan(spannable, end, spanEnd, style) + + if (spanStart in start..end) boundaries.add(spanStart) + if (spanEnd in start..end) boundaries.add(spanEnd) + } + + // Build the new merged spans chunk-by-chunk + val sortedBoundaries = boundaries.sorted() + + for (i in 0 until sortedBoundaries.size - 1) { + val chunkStart = sortedBoundaries[i] + val chunkEnd = sortedBoundaries[i + 1] + + // Find the old span that fully covers this specific chunk + val oldSpan = oldSpans.firstOrNull { it.second <= chunkStart && it.third >= chunkEnd }?.first + val oldStyle = oldSpan?.toCustomStyle() ?: CustomStyle() + val merged = styleUpdater(oldStyle) + + setCustomSpan(spannable, chunkStart, chunkEnd, merged) + } + } + + fun afterTextChanged( + s: Editable, + startCursorPosition: Int, + endCursorPosition: Int, + ) { + val isInsertion = endCursorPosition > startCursorPosition + + if (isInsertion) { + val activeStyle = view.spanState?.customStyle ?: CustomStyle() + + // Split existing spans if they don't match the current active values + splitCustomSpanOnInsertion(s, startCursorPosition, endCursorPosition, activeStyle) + + setCustomSpan(s, startCursorPosition, endCursorPosition, activeStyle) + } + + // Merge any adjacent spans that have the exact same style + collapseAdjacentCustomSpans(s, startCursorPosition, endCursorPosition) + } + + private fun splitCustomSpanOnInsertion( + spannable: Spannable, + insertStart: Int, + insertEnd: Int, + activeStyle: CustomStyle, + ) { + val spans = spannable.getSpans(insertStart, insertEnd, EnrichedInputCustomStyleSpan::class.java) + + for (span in spans) { + val spanStart = spannable.getSpanStart(span) + val spanEnd = spannable.getSpanEnd(span) + if (spanStart < 0 || spanEnd < 0) continue + + val spanStyle = span.toCustomStyle() + + // If the existing span perfectly matches the active state, leave it + if (spanStyle == activeStyle) continue + + // Spans differ. We must split the old span so it doesn't cover the new text + spannable.removeSpan(span) + + if (spanStart < insertStart) { + setCustomSpan(spannable, spanStart, insertStart, spanStyle) + } + if (spanEnd > insertEnd) { + setCustomSpan(spannable, insertEnd, spanEnd, spanStyle) + } + } + } + + private fun collapseAdjacentCustomSpans( + spannable: Spannable, + start: Int, + end: Int, + ) { + // Look slightly outside the typed area to catch adjacent spans + val searchStart = (start - 1).coerceAtLeast(0) + val searchEnd = (end + 1).coerceAtMost(spannable.length) + + val spans = spannable.getSpans(searchStart, searchEnd, EnrichedInputCustomStyleSpan::class.java) + if (spans.isEmpty()) return + + // Sort spans and extract their boundaries simultaneously + val sortedSpans = + spans + .mapNotNull { span -> + val spanStart = spannable.getSpanStart(span) + val spanEnd = spannable.getSpanEnd(span) + if (spanStart == -1 || spanEnd == -1) null else Triple(span, spanStart, spanEnd) + }.sortedBy { it.second } + + // Wipe all spans in this region immediately (we safely hold their data in sortedSpans) + sortedSpans.forEach { spannable.removeSpan(it.first) } + + var (_, currentStart, currentEnd) = sortedSpans[0] + var currentStyle = sortedSpans[0].first.toCustomStyle() + + // Iterate and merge + for (i in 1 until sortedSpans.size) { + val (span, spanStart, spanEnd) = sortedSpans[i] + val spanStyle = span.toCustomStyle() + + // If spans are touching/overlapping AND their values match perfectly extend the span + if (spanStart <= currentEnd && spanStyle == currentStyle) { + currentEnd = maxOf(currentEnd, spanEnd) + } else { + // Values changed or there is a gap. Commit the current merged block. + setCustomSpan(spannable, currentStart, currentEnd, currentStyle) + + // Start a new tracking block + currentStart = spanStart + currentEnd = spanEnd + currentStyle = spanStyle + } + } + + // Commit the final block + setCustomSpan(spannable, currentStart, currentEnd, currentStyle) + } + + private fun setCustomSpan( + spannable: Spannable, + start: Int, + end: Int, + style: CustomStyle, + ) { + if (start >= end || style.isEmpty()) return + + val span = + EnrichedInputCustomStyleSpan( + style.foregroundColor, + style.backgroundColor, + style.fontSize, + style.fontFamily, + view.context.assets, + view.allowFontScaling, + ) + spannable.setSpan( + span, + start, + end, + EnrichedSpanFlags.forSpan(span), + ) + } +} diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt index 5a772ded9..ee71a6234 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt @@ -5,10 +5,12 @@ import com.facebook.react.bridge.ColorPropConverter import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.ReadableMap import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight +import com.swmansion.enriched.common.DEFAULT_BACKGROUND_OPACITY import com.swmansion.enriched.common.EnrichedConstants import com.swmansion.enriched.common.EnrichedStyle import com.swmansion.enriched.common.MentionStyle import com.swmansion.enriched.common.pixelFromSpOrDp +import com.swmansion.enriched.common.withOpacity import com.swmansion.enriched.textinput.EnrichedTextInputView import kotlin.Float import kotlin.Int @@ -139,11 +141,11 @@ class HtmlStyle : EnrichedStyle { val codeBlockStyle = style.getMap("codeblock") codeBlockRadius = parseFloat(codeBlockStyle, "borderRadius") codeBlockColor = parseColor(codeBlockStyle, "color") - codeBlockBackgroundColor = parseColorWithOpacity(codeBlockStyle, "backgroundColor", 80) + codeBlockBackgroundColor = parseColorWithOpacity(codeBlockStyle, "backgroundColor") val inlineCodeStyle = style.getMap("code") inlineCodeColor = parseColor(inlineCodeStyle, "color") - inlineCodeBackgroundColor = parseColorWithOpacity(inlineCodeStyle, "backgroundColor", 80) + inlineCodeBackgroundColor = parseColorWithOpacity(inlineCodeStyle, "backgroundColor") val mentionStyle = style.getMap("mention") mentionsStyle = parseMentionsStyle(mentionStyle) @@ -161,7 +163,7 @@ class HtmlStyle : EnrichedStyle { private fun parseColorWithOpacity( map: ReadableMap?, key: String, - opacity: Int, + opacity: Int = DEFAULT_BACKGROUND_OPACITY, ): Int { val color = parseColor(map, key) return withOpacity(color, opacity) @@ -193,15 +195,6 @@ class HtmlStyle : EnrichedStyle { return parsedColor } - private fun withOpacity( - color: Int, - alpha: Int, - ): Int { - if (Color.alpha(color) != 255) return color - val a = alpha.coerceIn(0, 255) - return (color and 0x00FFFFFF) or (a shl 24) - } - private fun parseIsUnderline(map: ReadableMap?): Boolean { val underline = map?.getString("textDecorationLine") val isEnabled = underline == "underline" @@ -249,7 +242,7 @@ class HtmlStyle : EnrichedStyle { if (value == null) throw Error("Mention style for key '$key' cannot be null") val color = parseColor(value, "color") - val backgroundColor = parseColorWithOpacity(value, "backgroundColor", 80) + val backgroundColor = parseColorWithOpacity(value, "backgroundColor") val isUnderline = parseIsUnderline(value) val parsedStyle = MentionStyle(color, backgroundColor, isUnderline) parsedMentionsStyle.put(key, parsedStyle) diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSelection.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSelection.kt index 65840875d..d7890414c 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSelection.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSelection.kt @@ -4,11 +4,13 @@ import android.text.Editable import android.text.Spannable import com.facebook.react.bridge.ReactContext import com.facebook.react.uimanager.UIManagerHelper +import com.swmansion.enriched.common.CustomStyle import com.swmansion.enriched.common.EnrichedConstants import com.swmansion.enriched.textinput.EnrichedTextInputView import com.swmansion.enriched.textinput.events.OnChangeSelectionEvent import com.swmansion.enriched.textinput.events.OnLinkDetectedEvent import com.swmansion.enriched.textinput.events.OnMentionDetectedEvent +import com.swmansion.enriched.textinput.spans.EnrichedInputCustomStyleSpan import com.swmansion.enriched.textinput.spans.EnrichedInputLinkSpan import com.swmansion.enriched.textinput.spans.EnrichedInputMentionSpan import com.swmansion.enriched.textinput.spans.EnrichedSpans @@ -91,6 +93,7 @@ class EnrichedSelection( for ((style, config) in EnrichedSpans.inlineSpans) { state.setStart(style, getInlineStyleStart(config.clazz)) } + validateCustomStyles() } else { view.isRemovingMany = false } @@ -139,6 +142,56 @@ class EnrichedSelection( return styleStart } + private fun validateCustomStyles() { + val state = view.spanState ?: return + val (start, end) = getInlineSelection() + val spannable = view.text as Spannable + val style = getCustomStyleInRange(spannable, start, end) + state.setCustomStyle(style.foregroundColor, style.backgroundColor, style.fontSize, style.fontFamily) + } + + private fun getCustomStyleInRange( + spannable: Spannable, + start: Int, + end: Int, + ): CustomStyle { + // not-ranged selection + if (start == end) { + return spannable + .getSpans(start, end, EnrichedInputCustomStyleSpan::class.java) + .firstOrNull { spannable.getSpanStart(it) < start && spannable.getSpanEnd(it) >= end } + ?.toCustomStyle() ?: CustomStyle() + } + + val foregroundColors = mutableSetOf() + val backgroundColors = mutableSetOf() + val fontSizes = mutableSetOf() + val fontFamilies = mutableSetOf() + + var position = start + while (position < end) { + val next = spannable.nextSpanTransition(position, end, EnrichedInputCustomStyleSpan::class.java) + val span = + spannable.getSpans(position, next, EnrichedInputCustomStyleSpan::class.java).firstOrNull { + spannable.getSpanStart(it) <= position && spannable.getSpanEnd(it) >= next + } + + foregroundColors.add(span?.getForegroundColor()) + backgroundColors.add(span?.getBackgroundColor()) + fontSizes.add(span?.getFontSize()) + fontFamilies.add(span?.getFontFamily()) + + position = next + } + + return CustomStyle( + foregroundColor = foregroundColors.singleOrNull(), + backgroundColor = backgroundColors.singleOrNull(), + fontSize = fontSizes.singleOrNull(), + fontFamily = fontFamilies.singleOrNull(), + ) + } + fun getParagraphSelection(): Pair { val (currentStart, currentEnd) = getInlineSelection() val spannable = view.text as Spannable diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt index ec4e27285..1d7fb4a12 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpanState.kt @@ -5,6 +5,8 @@ import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.WritableMap import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.uimanager.events.EventDispatcher +import com.swmansion.enriched.common.CustomStyle +import com.swmansion.enriched.common.parser.EnrichedColorParser import com.swmansion.enriched.textinput.EnrichedTextInputView import com.swmansion.enriched.textinput.events.OnChangeStateEvent import com.swmansion.enriched.textinput.spans.EnrichedSpans @@ -54,6 +56,8 @@ class EnrichedSpanState( private set var currentAlignment: String = "auto" private set + var customStyle: CustomStyle? = null + private set fun setBoldStart(start: Int?) { this.boldStart = start @@ -155,6 +159,16 @@ class EnrichedSpanState( emitStateChangeEvent() } + fun setCustomStyle( + fgColor: Int?, + bgColor: Int?, + fontSize: Float? = null, + fontFamily: String? = null, + ) { + this.customStyle = CustomStyle(fgColor, bgColor, fontSize, fontFamily) + emitStateChangeEvent() + } + fun getStart(name: String): Int? { val start = when (name) { @@ -254,6 +268,7 @@ class EnrichedSpanState( payload.putMap("mention", getStyleState(activeStyles, EnrichedSpans.MENTION)) payload.putMap("checkboxList", getStyleState(activeStyles, EnrichedSpans.CHECKBOX_LIST)) payload.putString("alignment", currentAlignment) + payload.putMap("customStyle", getCustomStylePayload()) return payload } @@ -312,6 +327,28 @@ class EnrichedSpanState( return state } + private fun getCustomStylePayload(): WritableMap { + val customStyleMap = Arguments.createMap() + val customStyle = view.spanState?.customStyle + + if (customStyle?.foregroundColor != null) { + customStyleMap.putString("foregroundColor", EnrichedColorParser.colorToHex(customStyle.foregroundColor)) + } else { + customStyleMap.putString("foregroundColor", "") + } + + if (customStyle?.backgroundColor != null) { + customStyleMap.putString("backgroundColor", EnrichedColorParser.colorToHex(customStyle.backgroundColor)) + } else { + customStyleMap.putString("backgroundColor", "") + } + + customStyleMap.putDouble("fontSize", (customStyle?.fontSize ?: 0f).toDouble()) + customStyleMap.putString("fontFamily", customStyle?.fontFamily ?: "") + + return customStyleMap + } + companion object { const val NAME = "ReactNativeEnrichedView" } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt b/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt index bcc41294e..c283321bb 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/watchers/EnrichedTextWatcher.kt @@ -75,6 +75,7 @@ class EnrichedTextWatcher( private fun applyStyles(s: Editable) { view.inlineStyles?.afterTextChanged(s, startCursorPosition, endCursorPosition) + view.customStyles?.afterTextChanged(s, startCursorPosition, endCursorPosition) view.paragraphStyles?.afterTextChanged(s, endCursorPosition, previousTextLength) view.listStyles?.afterTextChanged(s, endCursorPosition, previousTextLength) view.alignmentStyles?.afterTextChanged(s, endCursorPosition, deletedText, anchorAlignmentToRestore) diff --git a/apps/example-web/src/components/Toolbar.css b/apps/example-web/src/components/Toolbar.css index 7b86fb8d2..a71cc14a7 100644 --- a/apps/example-web/src/components/Toolbar.css +++ b/apps/example-web/src/components/Toolbar.css @@ -77,3 +77,114 @@ .toolbar-btn:focus-visible { outline: none; } + +.toolbar-wrapper { + display: flex; + flex-direction: column; + width: 100%; +} + +.toolbar-color-btn { + flex-direction: column; + gap: 2px; +} + +.toolbar-color-label { + font-size: 15px; + font-weight: 700; + line-height: 1; + color: #fff; +} + +.toolbar-color-indicator { + display: block; + width: 20px; + height: 5px; + border-radius: 2px; + border: 1px solid; + background-color: transparent; + border-color: rgba(255, 255, 255, 0.4); +} + +.toolbar-color-picker { + display: flex; + flex-wrap: wrap; + padding: 8px; + gap: 6px; + background: rgba(0, 26, 114, 0.95); +} + +.toolbar-color-swatch { + width: 28px; + height: 28px; + border-radius: 4px; + border: 2px solid transparent; + cursor: pointer; + box-sizing: border-box; +} + +.toolbar-color-swatch--clear { + background: transparent; + color: #fff; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + border-color: rgba(255, 255, 255, 0.4); +} + +.toolbar-color-swatch--active { + border-color: #fff; + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.6); +} + +.toolbar-color-swatch:focus-visible { + outline: none; +} + +.toolbar-font-picker { + display: flex; + flex-wrap: nowrap; + overflow-x: auto; + padding: 8px; + gap: 8px; + background: rgba(0, 26, 114, 0.95); + scrollbar-width: thin; + -webkit-overflow-scrolling: touch; +} + +.toolbar-font-option { + min-width: 36px; + height: 28px; + padding: 0 8px; + border: 2px solid transparent; + border-radius: 14px; + background: rgba(255, 255, 255, 0.15); + color: #fff; + font-size: 14px; + font-weight: 600; + line-height: 1; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; +} + +.toolbar-font-option--clear { + width: 28px; + min-width: 28px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border-color: rgba(255, 255, 255, 0.4); + background: transparent; +} + +.toolbar-font-option--active { + background: rgb(0, 26, 114); + border-color: #fff; +} + +.toolbar-font-option:focus-visible { + outline: none; +} diff --git a/apps/example-web/src/components/Toolbar.tsx b/apps/example-web/src/components/Toolbar.tsx index a851ebc46..ccee76e4b 100644 --- a/apps/example-web/src/components/Toolbar.tsx +++ b/apps/example-web/src/components/Toolbar.tsx @@ -3,9 +3,43 @@ import type { EnrichedTextInputInstance, OnChangeStateEvent, } from 'react-native-enriched-html'; -import type { RefObject } from 'react'; +import { useState, type RefObject } from 'react'; import { useDragScroll } from '../hooks/useDragScroll'; +const COLORS = [ + '#808080', + '#FF0000', + '#FF6600', + '#FFFF00', + '#00FF00', + '#008000', + '#00FFFF', + '#0000FF', + '#800080', + '#FF00FF', + '#FF69B4', + '#A52A2A', + '#FFA500', + '#ADD8E6', +]; + +const FONT_SIZES = [12, 16, 20, 24, 28, 32, 36, 40]; + +const FONT_FAMILIES = [ + { label: 'Sans', value: 'Arial' }, + { label: 'Serif', value: 'Georgia' }, + { label: 'Mono', value: 'Courier New' }, + { label: 'System', value: 'system-ui' }, + { label: 'Cursive', value: 'cursive' }, +] as const; + +type OpenPicker = + | 'text-color' + | 'bg-color' + | 'font-size' + | 'font-family' + | null; + interface ToolbarProps { editorRef: RefObject; state: OnChangeStateEvent | null; @@ -61,6 +95,48 @@ export function Toolbar({ }: ToolbarProps) { const s = state; const dragScroll = useDragScroll(); + const [openPicker, setOpenPicker] = useState(null); + + const activeFgColor = s?.customStyle.foregroundColor ?? ''; + const activeBgColor = s?.customStyle.backgroundColor ?? ''; + const activeFontSize = s?.customStyle.fontSize ?? 0; + const activeFontFamily = s?.customStyle.fontFamily ?? ''; + const activeFontFamilyLabel = + FONT_FAMILIES.find((family) => family.value === activeFontFamily)?.label ?? + ''; + + const handleSelectFgColor = (color: string) => { + editorRef.current?.setStyle({ foregroundColor: color }); + setOpenPicker(null); + }; + const handleClearFgColor = () => { + editorRef.current?.setStyle({ foregroundColor: null }); + setOpenPicker(null); + }; + const handleSelectBgColor = (color: string) => { + editorRef.current?.setStyle({ backgroundColor: color }); + setOpenPicker(null); + }; + const handleClearBgColor = () => { + editorRef.current?.setStyle({ backgroundColor: null }); + setOpenPicker(null); + }; + const handleSelectFontSize = (size: number) => { + editorRef.current?.setStyle({ fontSize: size }); + setOpenPicker(null); + }; + const handleClearFontSize = () => { + editorRef.current?.setStyle({ fontSize: null }); + setOpenPicker(null); + }; + const handleSelectFontFamily = (family: string) => { + editorRef.current?.setStyle({ fontFamily: family }); + setOpenPicker(null); + }; + const handleClearFontFamily = () => { + editorRef.current?.setStyle({ fontFamily: null }); + setOpenPicker(null); + }; const toolbarItems = [ { @@ -213,34 +289,237 @@ export function Toolbar({ ]; return ( -
-
- {toolbarItems.map((item) => ( - { - item.onPress(editorRef.current); +
+
+
+ {toolbarItems.map((item) => ( + { + item.onPress(editorRef.current); + }} + /> + ))} + {alignmentItems.map((item) => ( + { + editorRef.current?.setTextAlignment(item.value); + }} + /> + ))} + + + + +
+ - ); } diff --git a/apps/example/ios/Podfile.lock b/apps/example/ios/Podfile.lock index 00354487c..3ceaad194 100644 --- a/apps/example/ios/Podfile.lock +++ b/apps/example/ios/Podfile.lock @@ -1626,7 +1626,7 @@ PODS: - React-utils (= 0.87.0) - ReactNativeDependencies - ReactNativeDependencies (0.87.0) - - ReactNativeEnrichedHtml (1.1.0): + - ReactNativeEnrichedHtml (1.1.1): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2005,7 +2005,7 @@ SPEC CHECKSUMS: ReactCodegen: b54c2a8d91c25df024024b078e9fd3cdf93ce8cf ReactCommon: 31d05b76329c8b3c9ef37fff8212f830925a2535 ReactNativeDependencies: ec5530432d8191b38e278117d498aaff05ea9d6f - ReactNativeEnrichedHtml: 862adfa2876f765869e6d27630d21f6e6e04bf25 + ReactNativeEnrichedHtml: 494c703157e37b8158fe254b6a62cb2e480453a1 SocketRocket: d57b1af80c029e12a75cd9b218287c67381e4593 Yoga: 58b502c52b14ad3c4468805d461fc37d275c2a42 diff --git a/apps/example/src/components/ColorPickerRow.tsx b/apps/example/src/components/ColorPickerRow.tsx new file mode 100644 index 000000000..ad428cb11 --- /dev/null +++ b/apps/example/src/components/ColorPickerRow.tsx @@ -0,0 +1,92 @@ +import { type FC } from 'react'; +import { Pressable, ScrollView, StyleSheet, Text } from 'react-native'; + +interface Props { + colors: string[]; + activeColor: string; + onSelectColor: (color: string) => void; + onClear: () => void; +} + +export const ColorPickerRow: FC = ({ + colors, + activeColor, + onSelectColor, + onClear, +}) => { + return ( + + + ✕ + + {colors.map((color) => { + const isActive = color.toLowerCase() === activeColor?.toLowerCase(); + const swatchId = `color-swatch-${color.replace('#', '').toUpperCase()}`; + return ( + onSelectColor(color)} + style={[ + styles.swatch, + { backgroundColor: color }, + isActive && styles.swatchActive, + color === '#FFFFFF' && styles.swatchBordered, + ]} + /> + ); + })} + + ); +}; + +const SWATCH_SIZE = 28; + +const styles = StyleSheet.create({ + container: { + width: '100%', + backgroundColor: 'rgba(0, 26, 114, 0.9)', + }, + content: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 8, + paddingVertical: 8, + gap: 8, + }, + clearButton: { + width: SWATCH_SIZE, + height: SWATCH_SIZE, + borderRadius: SWATCH_SIZE / 2, + backgroundColor: 'rgba(255,255,255,0.15)', + justifyContent: 'center', + alignItems: 'center', + }, + clearText: { + color: 'white', + fontSize: 14, + lineHeight: 16, + }, + swatch: { + width: SWATCH_SIZE, + height: SWATCH_SIZE, + borderRadius: SWATCH_SIZE / 2, + }, + swatchActive: { + borderWidth: 3, + borderColor: 'white', + }, + swatchBordered: { + borderWidth: 1, + borderColor: 'rgba(0,0,0,0.25)', + }, +}); diff --git a/apps/example/src/components/FontFamilyPickerRow.tsx b/apps/example/src/components/FontFamilyPickerRow.tsx new file mode 100644 index 000000000..f4f907748 --- /dev/null +++ b/apps/example/src/components/FontFamilyPickerRow.tsx @@ -0,0 +1,100 @@ +import { type FC } from 'react'; +import { Pressable, ScrollView, StyleSheet, Text } from 'react-native'; + +export interface FontFamilyOption { + label: string; + value: string; +} + +interface Props { + families: FontFamilyOption[]; + activeFamily: string; + onSelectFamily: (family: string) => void; + onClear: () => void; +} + +export const FontFamilyPickerRow: FC = ({ + families, + activeFamily, + onSelectFamily, + onClear, +}) => { + return ( + + + ✕ + + {families.map(({ label, value }) => { + const isActive = value === activeFamily; + return ( + onSelectFamily(value)} + style={[styles.familyButton, isActive && styles.familyButtonActive]} + > + + {label} + + + ); + })} + + ); +}; + +const styles = StyleSheet.create({ + container: { + width: '100%', + backgroundColor: 'rgba(0, 26, 114, 0.9)', + }, + content: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 8, + paddingVertical: 8, + gap: 8, + }, + clearButton: { + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: 'rgba(255,255,255,0.15)', + justifyContent: 'center', + alignItems: 'center', + }, + clearText: { + color: 'white', + fontSize: 14, + lineHeight: 16, + }, + familyButton: { + minWidth: 36, + height: 28, + paddingHorizontal: 8, + borderRadius: 14, + backgroundColor: 'rgba(255,255,255,0.15)', + justifyContent: 'center', + alignItems: 'center', + }, + familyButtonActive: { + backgroundColor: 'rgb(0, 26, 114)', + borderWidth: 2, + borderColor: 'white', + }, + familyText: { + color: 'white', + fontSize: 14, + fontWeight: '600', + lineHeight: 16, + }, +}); diff --git a/apps/example/src/components/FontSizePickerRow.tsx b/apps/example/src/components/FontSizePickerRow.tsx new file mode 100644 index 000000000..c22896909 --- /dev/null +++ b/apps/example/src/components/FontSizePickerRow.tsx @@ -0,0 +1,93 @@ +import { type FC } from 'react'; +import { Pressable, ScrollView, StyleSheet, Text } from 'react-native'; + +interface Props { + sizes: number[]; + activeSize: number; + onSelectSize: (size: number) => void; + onClear: () => void; +} + +export const FontSizePickerRow: FC = ({ + sizes, + activeSize, + onSelectSize, + onClear, +}) => { + return ( + + + ✕ + + {sizes.map((size) => { + const isActive = size === activeSize; + return ( + onSelectSize(size)} + style={[styles.sizeButton, isActive && styles.sizeButtonActive]} + > + {size} + + ); + })} + + ); +}; + +const styles = StyleSheet.create({ + container: { + width: '100%', + backgroundColor: 'rgba(0, 26, 114, 0.9)', + }, + content: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 8, + paddingVertical: 8, + gap: 8, + }, + clearButton: { + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: 'rgba(255,255,255,0.15)', + justifyContent: 'center', + alignItems: 'center', + }, + clearText: { + color: 'white', + fontSize: 14, + lineHeight: 16, + }, + sizeButton: { + minWidth: 36, + height: 28, + paddingHorizontal: 8, + borderRadius: 14, + backgroundColor: 'rgba(255,255,255,0.15)', + justifyContent: 'center', + alignItems: 'center', + }, + sizeButtonActive: { + backgroundColor: 'rgb(0, 26, 114)', + borderWidth: 2, + borderColor: 'white', + }, + sizeText: { + color: 'white', + fontSize: 14, + fontWeight: '600', + lineHeight: 16, + }, +}); diff --git a/apps/example/src/components/Toolbar.tsx b/apps/example/src/components/Toolbar.tsx index 20109e390..5d6b6ee62 100644 --- a/apps/example/src/components/Toolbar.tsx +++ b/apps/example/src/components/Toolbar.tsx @@ -1,5 +1,16 @@ -import { FlatList, type ListRenderItemInfo, StyleSheet } from 'react-native'; +import { useState } from 'react'; +import { + FlatList, + type ListRenderItemInfo, + Pressable, + StyleSheet, + Text, + View, +} from 'react-native'; import { ToolbarButton } from './ToolbarButton'; +import { ColorPickerRow } from './ColorPickerRow'; +import { FontSizePickerRow } from './FontSizePickerRow'; +import { FontFamilyPickerRow } from './FontFamilyPickerRow'; import type { OnChangeStateEvent, EnrichedTextInputInstance, @@ -8,6 +19,35 @@ import type { FC } from 'react'; const GRID_COLUMNS = 8; +const COLORS = [ + '#000000', + '#FFFFFF', + '#808080', + '#FF0000', + '#FF6600', + '#FFFF00', + '#00FF00', + '#008000', + '#00FFFF', + '#0000FF', + '#800080', + '#FF00FF', + '#FF69B4', + '#A52A2A', + '#FFA500', + '#ADD8E6', +]; + +const FONT_SIZES = [12, 16, 20, 24, 28, 32, 36, 40]; + +const FONT_FAMILIES = [ + { label: 'Bold', value: 'Nunito-Bold' }, + { label: 'Italic', value: 'Nunito-Italic' }, + { label: 'Code', value: 'CascadiaCode-Regular' }, + { label: 'Code Bold', value: 'CascadiaCode-Bold' }, + { label: 'Courier', value: 'Courier' }, +] as const; + const STYLE_ITEMS = [ { name: 'bold', @@ -97,10 +137,32 @@ const STYLE_ITEMS = [ name: 'align-right', icon: 'align-right', }, + { + name: 'text-color', + text: 'A', + }, + { + name: 'bg-color', + text: 'BG', + }, + { + name: 'font-size', + text: 'Aa', + }, + { + name: 'font-family', + text: 'Ff', + }, ] as const; type Item = (typeof STYLE_ITEMS)[number]; type StylesState = OnChangeStateEvent; +type OpenPicker = + | 'text-color' + | 'bg-color' + | 'font-size' + | 'font-family' + | null; export interface ToolbarProps { stylesState: StylesState; @@ -117,6 +179,25 @@ export const Toolbar: FC = ({ onSelectImage, layout = 'horizontal', }) => { + const [openPicker, setOpenPicker] = useState(null); + + const activeFgColor = stylesState.customStyle?.foregroundColor ?? ''; + const activeBgColor = stylesState.customStyle?.backgroundColor ?? ''; + const activeFontSize = stylesState.customStyle?.fontSize ?? 0; + const activeFontFamily = stylesState.customStyle?.fontFamily ?? ''; + const activeFontFamilyLabel = + FONT_FAMILIES.find((family) => family.value === activeFontFamily)?.label ?? + ''; + + const fgIndicatorColor = + activeFgColor.length > 0 ? activeFgColor : 'transparent'; + const fgIndicatorBorder = + activeFgColor.length > 0 ? activeFgColor : 'rgba(255,255,255,0.4)'; + const bgIndicatorColor = + activeBgColor.length > 0 ? activeBgColor : 'transparent'; + const bgIndicatorBorder = + activeBgColor.length > 0 ? activeBgColor : 'rgba(255,255,255,0.4)'; + const handlePress = (item: Item) => { const currentRef = editorRef?.current; if (!currentRef) return; @@ -168,7 +249,6 @@ export const Toolbar: FC = ({ editorRef.current?.toggleOrderedList(); break; case 'checkbox-list': - // Make checkbox checked by default editorRef.current?.toggleCheckboxList(true); break; case 'link': @@ -289,6 +369,104 @@ export const Toolbar: FC = ({ }; const renderItem = ({ item }: ListRenderItemInfo) => { + if (item.name === 'text-color') { + return ( + + setOpenPicker((prev) => + prev === 'text-color' ? null : 'text-color' + ) + } + style={[ + styles.colorButton, + layout === 'grid' ? styles.gridItem : undefined, + openPicker === 'text-color' && styles.colorButtonActive, + ]} + > + A + + + ); + } + + if (item.name === 'bg-color') { + return ( + + setOpenPicker((prev) => (prev === 'bg-color' ? null : 'bg-color')) + } + style={[ + styles.colorButton, + layout === 'grid' ? styles.gridItem : undefined, + openPicker === 'bg-color' && styles.colorButtonActive, + ]} + > + BG + + + ); + } + + if (item.name === 'font-size') { + const fontSizeLabel = activeFontSize > 0 ? String(activeFontSize) : 'Aa'; + return ( + + setOpenPicker((prev) => (prev === 'font-size' ? null : 'font-size')) + } + style={[ + styles.colorButton, + layout === 'grid' ? styles.gridItem : undefined, + (openPicker === 'font-size' || activeFontSize > 0) && + styles.colorButtonActive, + ]} + > + {fontSizeLabel} + + ); + } + + if (item.name === 'font-family') { + const fontFamilyLabel = activeFontFamilyLabel || 'Ff'; + return ( + + setOpenPicker((prev) => + prev === 'font-family' ? null : 'font-family' + ) + } + style={[ + styles.colorButton, + layout === 'grid' ? styles.gridItem : undefined, + (openPicker === 'font-family' || activeFontFamily.length > 0) && + styles.colorButtonActive, + ]} + > + {fontFamilyLabel} + + ); + } + return ( = ({ const keyExtractor = (item: Item) => item.name; + const handleSelectFgColor = (color: string) => { + editorRef?.current?.setStyle({ foregroundColor: color }); + setOpenPicker(null); + }; + + const handleClearFgColor = () => { + editorRef?.current?.setStyle({ foregroundColor: null }); + setOpenPicker(null); + }; + + const handleSelectBgColor = (color: string) => { + editorRef?.current?.setStyle({ backgroundColor: color }); + setOpenPicker(null); + }; + + const handleClearBgColor = () => { + editorRef?.current?.setStyle({ backgroundColor: null }); + setOpenPicker(null); + }; + + const handleSelectFontSize = (size: number) => { + editorRef?.current?.setStyle({ fontSize: size }); + setOpenPicker(null); + }; + + const handleClearFontSize = () => { + editorRef?.current?.setStyle({ fontSize: null }); + setOpenPicker(null); + }; + + const handleSelectFontFamily = (family: string) => { + editorRef?.current?.setStyle({ fontFamily: family }); + setOpenPicker(null); + }; + + const handleClearFontFamily = () => { + editorRef?.current?.setStyle({ fontFamily: null }); + setOpenPicker(null); + }; + return ( - + + + {openPicker === 'text-color' && ( + + )} + {openPicker === 'bg-color' && ( + + )} + {openPicker === 'font-size' && ( + + )} + {openPicker === 'font-family' && ( + + )} + ); }; const styles = StyleSheet.create({ - container: { + wrapper: { + width: '100%', + }, + list: { width: '100%', }, gridItem: { flexBasis: `${100 / GRID_COLUMNS}%`, aspectRatio: 1, }, + colorButton: { + justifyContent: 'center', + alignItems: 'center', + width: 56, + height: 56, + backgroundColor: 'rgba(0, 26, 114, 0.8)', + gap: 2, + }, + colorButtonActive: { + backgroundColor: 'rgb(0, 26, 114)', + }, + colorButtonLabel: { + color: 'white', + fontSize: 15, + fontWeight: '700', + lineHeight: 17, + textAlign: 'center', + }, + colorIndicator: { + width: 20, + height: 5, + borderRadius: 2, + borderWidth: 1, + }, }); diff --git a/apps/example/src/constants/editorConfig.ts b/apps/example/src/constants/editorConfig.ts index 2b9d44b0b..f29ac11c0 100644 --- a/apps/example/src/constants/editorConfig.ts +++ b/apps/example/src/constants/editorConfig.ts @@ -33,6 +33,12 @@ export const DEFAULT_STYLES: StylesState = { mention: DEFAULT_STYLE_STATE, checkboxList: DEFAULT_STYLE_STATE, alignment: 'auto', + customStyle: { + foregroundColor: '', + backgroundColor: '', + fontSize: 0, + fontFamily: '', + }, }; export const DEFAULT_LINK_STATE = { diff --git a/apps/example/src/hooks/useKeyboardHeight.ts b/apps/example/src/hooks/useKeyboardHeight.ts new file mode 100644 index 000000000..3891f11dd --- /dev/null +++ b/apps/example/src/hooks/useKeyboardHeight.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from 'react'; +import { Keyboard } from 'react-native'; + +export function useKeyboardHeight() { + const [keyboardHeight, setKeyboardHeight] = useState(0); + + useEffect(() => { + const showSubscription = Keyboard.addListener('keyboardDidShow', (e) => { + setKeyboardHeight(e.endCoordinates.height); + }); + const hideSubscription = Keyboard.addListener('keyboardDidHide', () => { + setKeyboardHeight(0); + }); + + return () => { + showSubscription.remove(); + hideSubscription.remove(); + }; + }, []); + + return keyboardHeight; +} diff --git a/apps/example/src/screens/TestScreen.tsx b/apps/example/src/screens/TestScreen.tsx index 160fda327..1e64d5225 100644 --- a/apps/example/src/screens/TestScreen.tsx +++ b/apps/example/src/screens/TestScreen.tsx @@ -8,6 +8,7 @@ import { MentionPopup } from '../components/MentionPopup'; import { ImageModal } from '../components/ImageModal'; import { ValueModal } from '../components/ValueModal'; import { useEditorState } from '../hooks/useEditorState'; +import { useKeyboardHeight } from '../hooks/useKeyboardHeight'; import { LINK_REGEX, htmlStyle, @@ -25,12 +26,16 @@ export function TestScreen({ }: TestScreenProps) { const editor = useEditorState(); const [sizeMode, setSizeMode] = useState<'base' | 'max'>('base'); + const keyboardHeight = useKeyboardHeight(); return ( diff --git a/cpp/parser/GumboNormalizer.c b/cpp/parser/GumboNormalizer.c index 5a6210f15..2ccbcd885 100644 --- a/cpp/parser/GumboNormalizer.c +++ b/cpp/parser/GumboNormalizer.c @@ -852,9 +852,47 @@ static void walk_node(GumboNode *node, buffer_t *out) { const char *sval = get_attr(el, "style"); size_t slen = sval ? strlen(sval) : 0; css_styles_t s = parse_css_style(sval, slen); + + size_t fg_len = 0; + const char *fg = find_css_value(sval, slen, "color", &fg_len); + + size_t bg_len = 0; + const char *bg = find_css_value(sval, slen, "background-color", &bg_len); + + int has_fg = (fg && fg_len > 0); + int has_bg = (bg && bg_len > 0); + + // emit the wrapper span if colors exist + if (has_fg || has_bg) { + buffer_append_str(out, ""); + } + + // handle inner formatting (, , etc.) and children emit_styles_open(out, s); walk_children(node, out); emit_styles_close(out, s); + + // close the wrapper span + if (has_fg || has_bg) { + buffer_append_str(out, ""); + } + return; } diff --git a/cpp/tests/GumboParserTest.cpp b/cpp/tests/GumboParserTest.cpp index ff06232c9..fefbe99e9 100644 --- a/cpp/tests/GumboParserTest.cpp +++ b/cpp/tests/GumboParserTest.cpp @@ -253,6 +253,75 @@ TEST(GumboParserTest, SpanRemappings) { "x"), "x"); + + // Foreground color only + EXPECT_EQ( + GumboParser::normalizeHtml("x"), + "x"); + EXPECT_EQ( + GumboParser::normalizeHtml("x"), + "x"); + EXPECT_EQ( + GumboParser::normalizeHtml( + "x"), + "x"); + + // Background color only + EXPECT_EQ( + GumboParser::normalizeHtml( + "x"), + "x"); + EXPECT_EQ( + GumboParser::normalizeHtml( + "x"), + "x"); + + // Both colors combined + EXPECT_EQ( + GumboParser::normalizeHtml( + "x"), + "x"); + EXPECT_EQ( + GumboParser::normalizeHtml( + "x"), + "x"); + + // Color + Single Formatter + EXPECT_EQ( + GumboParser::normalizeHtml( + "x"), + "x"); + EXPECT_EQ( + GumboParser::normalizeHtml( + "x"), + "x"); + EXPECT_EQ( + GumboParser::normalizeHtml( + "x"), + "x"); + EXPECT_EQ( + GumboParser::normalizeHtml( + "x"), + "x"); + + // Color + Multiple inline styles + EXPECT_EQ( + GumboParser::normalizeHtml( + "x"), + "x"); + + // Mix of colors and inline styles + EXPECT_EQ( + GumboParser::normalizeHtml( + "x"), + "x"); } TEST(GumboParserTest, EnrichedTagRemappings) { diff --git a/docs/docs/api-reference/enriched-text-input.md b/docs/docs/api-reference/enriched-text-input.md index 382e60a39..eb4339d43 100644 --- a/docs/docs/api-reference/enriched-text-input.md +++ b/docs/docs/api-reference/enriched-text-input.md @@ -347,6 +347,12 @@ interface OnChangeStateEvent { mention: StyleState; checkboxList: StyleState; alignment: string; + customStyle: { + foregroundColor: string; + backgroundColor: string; + fontSize: number; + fontFamily: string; + }; } ``` @@ -356,6 +362,10 @@ interface OnChangeStateEvent { - `isConflicting` - toggling the style removes a conflicting active style - `alignment` - current text alignment of the paragraph at the cursor: `'left'`, `'center'`, `'right'`, `'justify'`, or `'auto'` +- `customStyle` - the [custom style](/rich-text-formatting/custom-style) values + within the current selection. A field that isn't set, or + differs across the selection, is empty - `''` for strings and `0` for + `fontSize`. Colors are reported as hex strings (e.g. `#FF0000`) :::note @@ -973,6 +983,27 @@ up with natural alignment, the same as `'auto'`. ::: +### `.setStyle()` + +```ts +setStyle: (customStyle: { + foregroundColor?: ColorValue | null; + backgroundColor?: ColorValue | null; + fontSize?: number | null; + fontFamily?: string | null; +}) => void; +``` + +Sets the [custom style](/rich-text-formatting/custom-style) at the current +selection. Omitting a field leaves the given formatting untouched, whereas passing `null` for a field removes it. + +- `foregroundColor` - text color; use a hex (e.g. `#FFE066`) or named (e.g. + `'red'`) color +- `backgroundColor` - text highlight color; use a hex (e.g. `#FFE066`) or named + (e.g. `'red'`) color +- `fontSize` - font size of the text +- `fontFamily` - font family of the text + ### `.startMention()` ```ts diff --git a/docs/docs/fundamentals/core-concepts.md b/docs/docs/fundamentals/core-concepts.md index 52fd5593b..e391df2b9 100644 --- a/docs/docs/fundamentals/core-concepts.md +++ b/docs/docs/fundamentals/core-concepts.md @@ -71,7 +71,7 @@ The HTML format that both components expect is identical, what allows you to int ## The style state model -Not every style can be combined with every other. For example, a paragraph can't be both a heading and a list item, code blocks don't support inline formatting such as bold or italic. The editor tracks this and reports it through `onChangeState`, which gives each style three booleans: +Not every style can be combined with every other. For example, a paragraph can't be both a heading and a list item, code blocks don't support inline formatting such as bold or italic. The editor tracks this and reports it through `onChangeState`, which gives the majority of styles three booleans: - **`isActive`** - whether the style is applied at the current selection. Use it to highlight a toolbar button. @@ -82,6 +82,8 @@ Not every style can be combined with every other. For example, a paragraph can't For example, switching a blockquote paragraph to a heading removes the blockquote. Use it to hint that the toggle is a swap, not an addition. +Only [text alignment](/rich-text-formatting/text-alignment) and [custom style](/rich-text-formatting/custom-style) do not expose those three booleans in the `onChangeState` event, as they can be applied on any existing style. Instead, they simply carry their current value. To see the exact shape of the event's payload, see the `onChangeState` [API reference](/api-reference/enriched-text-input#onchangestate). + Driving your toolbar from these three flags keeps the UI honest: buttons light up and grey out according to the editor's state. diff --git a/docs/docs/fundamentals/html-format-and-supported-tags.md b/docs/docs/fundamentals/html-format-and-supported-tags.md index 962dae581..ce2488e26 100644 --- a/docs/docs/fundamentals/html-format-and-supported-tags.md +++ b/docs/docs/fundamentals/html-format-and-supported-tags.md @@ -32,6 +32,10 @@ Both show up in the [`onChangeState`](/fundamentals/core-concepts#the-style-stat | Link | `` | ``, ``, `` | ``, `` | | 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; +};