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 884e3312e..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 e74ac86bc..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 ffaba55fb..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 96f80aa5c..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 358b63cb9..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 c34ec5ed7..dd2d16428 100644 Binary files a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_3.png and b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_3.png differ diff --git a/.maestro/enrichedText/flows/custom_style_colors_visual.yaml b/.maestro/enrichedText/flows/custom_style_colors_visual.yaml new file mode 100644 index 000000000..e304b5b94 --- /dev/null +++ b/.maestro/enrichedText/flows/custom_style_colors_visual.yaml @@ -0,0 +1,28 @@ +appId: swmansion.enriched.example +--- +# Validates that custom style colors are displayed correctly +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +- tapOn: + id: 'toggle-enriched-text-screen-button' + +- runFlow: + file: '../subflows/set_enriched_text_value.yaml' + env: + VALUE: > + +
Standard 6-digit Hex text
+White text on black background
+25% transparent green background
+50% transparent blue text
+Red 3-digit shorthand text
+Regular family
+24px plain
+Bold family
+32 Courier
+Bold 20px Italic family Code bold
+ + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'custom_style_fonts_visual' diff --git a/.maestro/enrichedText/screenshots/android/custom_style_colors_visual.png b/.maestro/enrichedText/screenshots/android/custom_style_colors_visual.png new file mode 100644 index 000000000..37f78b87e Binary files /dev/null and b/.maestro/enrichedText/screenshots/android/custom_style_colors_visual.png differ diff --git a/.maestro/enrichedText/screenshots/android/custom_style_fonts_visual.png b/.maestro/enrichedText/screenshots/android/custom_style_fonts_visual.png new file mode 100644 index 000000000..778ef6313 Binary files /dev/null and b/.maestro/enrichedText/screenshots/android/custom_style_fonts_visual.png differ diff --git a/.maestro/enrichedText/screenshots/ios/custom_style_colors_visual.png b/.maestro/enrichedText/screenshots/ios/custom_style_colors_visual.png new file mode 100644 index 000000000..5e42bea28 Binary files /dev/null and b/.maestro/enrichedText/screenshots/ios/custom_style_colors_visual.png differ diff --git a/.maestro/enrichedText/screenshots/ios/custom_style_fonts_visual.png b/.maestro/enrichedText/screenshots/ios/custom_style_fonts_visual.png new file mode 100644 index 000000000..03f364cc3 Binary files /dev/null and b/.maestro/enrichedText/screenshots/ios/custom_style_fonts_visual.png differ diff --git a/.playwright/screenshots/custom-style-colors-visual.png b/.playwright/screenshots/custom-style-colors-visual.png new file mode 100644 index 000000000..2b55288e7 Binary files /dev/null and b/.playwright/screenshots/custom-style-colors-visual.png differ diff --git a/.playwright/screenshots/custom-style-fonts-visual.png b/.playwright/screenshots/custom-style-fonts-visual.png new file mode 100644 index 000000000..db74038af Binary files /dev/null and b/.playwright/screenshots/custom-style-fonts-visual.png differ diff --git a/.playwright/screenshots/enriched-text-blockquote-code-codeblock.png b/.playwright/screenshots/enriched-text-blockquote-code-codeblock.png index cd7fc5f52..984ed253d 100644 Binary files a/.playwright/screenshots/enriched-text-blockquote-code-codeblock.png and b/.playwright/screenshots/enriched-text-blockquote-code-codeblock.png differ diff --git a/.playwright/screenshots/inline-styles.png b/.playwright/screenshots/inline-styles.png index ff7c2f693..1ceb631d9 100644 Binary files a/.playwright/screenshots/inline-styles.png and b/.playwright/screenshots/inline-styles.png differ diff --git a/.playwright/tests/customStyleColors.spec.ts b/.playwright/tests/customStyleColors.spec.ts new file mode 100644 index 000000000..d0eeb9d21 --- /dev/null +++ b/.playwright/tests/customStyleColors.spec.ts @@ -0,0 +1,308 @@ +import { test, expect, type Page } from '@playwright/test'; + +import { + editorLocator, + getSerializedHtml, + gotoVisualRegression, + setEditorHtml, +} from '../helpers/visual-regression'; +import { toolbarButton } from '../helpers/toolbar'; + +function textColorButton(page: Page) { + return page.locator('[data-testid="toolbar-text-color"]'); +} + +function bgColorButton(page: Page) { + return page.locator('[data-testid="toolbar-bg-color"]'); +} + +function colorSwatch(page: Page, color: string) { + return page.locator( + `[data-testid="toolbar-color-swatch-${color.replace('#', '')}"]` + ); +} + +function colorSwatchClear(page: Page) { + return page.locator('[data-testid="toolbar-color-swatch-clear"]'); +} + +async function applyTextColor(page: Page, color: string) { + await textColorButton(page).click(); + await colorSwatch(page, color).click(); +} + +async function clearTextColor(page: Page) { + await textColorButton(page).click(); + await colorSwatchClear(page).click(); +} + +async function applyBgColor(page: Page, color: string) { + await bgColorButton(page).click(); + await colorSwatch(page, color).click(); +} + +async function clearBgColor(page: Page) { + await bgColorButton(page).click(); + await colorSwatchClear(page).click(); +} + +const ROUND_TRIP_CASES: { name: string; input: string; expected: string }[] = [ + { + name: 'foreground color only', + input: 'Quote code family
Red text
', + expected: + 'Red text
', + }, + { + name: 'background color only', + input: + 'Yellow bg
', + expected: + 'Yellow bg
', + }, + { + name: 'foreground and background color', + input: + 'Both
', + expected: + 'Both
', + }, + { + name: '8-digit hex background (transparent)', + input: + '25% green bg
', + expected: + '25% green bg
', + }, + { + name: '8-digit hex foreground (transparent)', + input: + '50% blue text
', + expected: + '50% blue text
', + }, + { + name: 'color inside heading', + input: + 'Bold red
', + expected: + 'Bold red
', + }, + { + name: 'multiple colored spans in one paragraph', + input: + 'Red plain Blue
', + expected: + 'Red plain Blue
', + }, +]; + +test.describe('custom style colors - HTML serialization', () => { + test.beforeEach(async ({ page }) => { + await gotoVisualRegression(page); + }); + + for (const { name, input, expected } of ROUND_TRIP_CASES) { + test(name, async ({ page }) => { + await setEditorHtml(page, input); + await expect.poll(async () => getSerializedHtml(page)).toBe(expected); + }); + } +}); + +test('custom style colors visual regression', async ({ page }) => { + await gotoVisualRegression(page); + + const html = [ + '', + 'Standard 6-digit hex text
', + 'White text on black background
', + '25% transparent green background
', + '50% transparent blue text
', + 'Red 3-digit shorthand text
', + 'Red text
' + ); + }); + + test('clear foreground color stops coloring new text', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyTextColor(page, '#FF0000'); + await editor.pressSequentially('Red', { delay: 80 }); + await clearTextColor(page); + await editor.pressSequentially(' plain', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + 'Red plain
' + ); + }); + + test('apply background color then type text', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyBgColor(page, '#FFFF00'); + await editor.pressSequentially('Yellow back', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + 'Yellow back
' + ); + }); + + test('clear background color stops coloring new text', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyBgColor(page, '#FFFF00'); + await editor.pressSequentially('Yellow', { delay: 80 }); + await clearBgColor(page); + await editor.pressSequentially(' plain', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + 'Yellow plain
' + ); + }); + + test('apply foreground and background color together', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyTextColor(page, '#FF0000'); + await applyBgColor(page, '#FFFF00'); + await editor.pressSequentially('Red+Yellow', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + 'Red+Yellow
' + ); + }); + + test('foreground color with bold', async ({ page }) => { + const editor = editorLocator(page); + const boldBtn = toolbarButton(page, 'bold'); + await editor.click(); + + await boldBtn.click(); + await applyTextColor(page, '#FF0000'); + await editor.pressSequentially('Bold red', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + 'Bold red
' + ); + }); + + test('background color with italic', async ({ page }) => { + const editor = editorLocator(page); + const italicBtn = toolbarButton(page, 'italic'); + await editor.click(); + + await italicBtn.click(); + await applyBgColor(page, '#FFFF00'); + await editor.pressSequentially('Italic yellow back', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + 'Italic yellow back
' + ); + }); + + test('toolbar text-color button shows active swatch when color is set', async ({ + page, + }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyTextColor(page, '#FF0000'); + + // Re-open the picker – the chosen swatch should be marked as active + await textColorButton(page).click(); + await expect(colorSwatch(page, '#FF0000')).toHaveClass( + /toolbar-color-swatch--active/ + ); + + // Close the picker + await textColorButton(page).click(); + }); + + test('custom style is not active right after clearing it', async ({ + page, + }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyTextColor(page, '#FF0000'); + await editor.pressSequentially('Red', { delay: 80 }); + await clearTextColor(page); + + // The cursor still sits right after the red text, but the color was + // cleared for the next typed characters, so no swatch should be active + await textColorButton(page).click(); + await expect(colorSwatch(page, '#FF0000')).not.toHaveClass( + /toolbar-color-swatch--active/ + ); + + // Close the picker + await textColorButton(page).click(); + }); + + test('toolbar bg-color button shows active swatch when color is set', async ({ + page, + }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyBgColor(page, '#FFFF00'); + + // Re-open the picker – the chosen swatch should be marked as active + await bgColorButton(page).click(); + await expect(colorSwatch(page, '#FFFF00')).toHaveClass( + /toolbar-color-swatch--active/ + ); + + // Close the picker + await bgColorButton(page).click(); + }); +}); diff --git a/.playwright/tests/customStyleFonts.spec.ts b/.playwright/tests/customStyleFonts.spec.ts new file mode 100644 index 000000000..4b4afc701 --- /dev/null +++ b/.playwright/tests/customStyleFonts.spec.ts @@ -0,0 +1,330 @@ +import { test, expect, type Page } from '@playwright/test'; + +import { + editorLocator, + focusEnrichedEditable, + getSerializedHtml, + gotoVisualRegression, + setEditorHtml, +} from '../helpers/visual-regression'; +import { toolbarButton } from '../helpers/toolbar'; + +function fontSizeButton(page: Page) { + return page.locator('[data-testid="toolbar-font-size"]'); +} + +function fontFamilyButton(page: Page) { + return page.locator('[data-testid="toolbar-font-family"]'); +} + +function fontSizeOption(page: Page, size: number) { + return page.locator(`[data-testid="font-size-${size}"]`); +} + +function fontFamilyOption(page: Page, value: string) { + return page.locator(`[data-testid="font-family-${value}"]`); +} + +function fontSizeClear(page: Page) { + return page.locator('[data-testid="font-size-clear"]'); +} + +function fontFamilyClear(page: Page) { + return page.locator('[data-testid="font-family-clear"]'); +} + +async function applyFontSize(page: Page, size: number) { + await fontSizeButton(page).click(); + await fontSizeOption(page, size).click(); +} + +async function clearFontSize(page: Page) { + await fontSizeButton(page).click(); + await fontSizeClear(page).click(); +} + +async function applyFontFamily(page: Page, value: string) { + await fontFamilyButton(page).click(); + await fontFamilyOption(page, value).click(); +} + +async function clearFontFamily(page: Page) { + await fontFamilyButton(page).click(); + await fontFamilyClear(page).click(); +} + +const ROUND_TRIP_CASES: { name: string; input: string; expected: string }[] = [ + { + name: 'font size only', + input: + 'Sized text
', + expected: + 'Sized text
', + }, + { + name: 'font family only', + input: + 'Courier text
', + expected: + 'Courier text
', + }, + { + name: 'font family with a comma-separated fallback is reduced to first family', + input: + 'Courier text
', + expected: + 'Courier text
', + }, + { + name: 'font size and font family together', + input: + 'Both
', + expected: + 'Both
', + }, + { + name: 'font size and color together', + input: + 'Red big
', + expected: + 'Red big
', + }, + { + name: 'font size inside heading', + input: + 'Bold big
', + expected: + 'Bold big
', + }, + { + name: 'multiple sized spans in one paragraph', + input: + 'Small plain Big
', + expected: + 'Small plain Big
', + }, +]; + +test.describe('custom style fonts - HTML serialization', () => { + test.beforeEach(async ({ page }) => { + await gotoVisualRegression(page); + }); + + for (const { name, input, expected } of ROUND_TRIP_CASES) { + test(name, async ({ page }) => { + await setEditorHtml(page, input); + await expect.poll(async () => getSerializedHtml(page)).toBe(expected); + }); + } +}); + +test('custom style fonts visual regression', async ({ page }) => { + await gotoVisualRegression(page); + + const html = [ + '', + 'Regular family
', + '24px plain
', + 'Sans family
', + '32 Courier
', + 'Bold 20px ',
+ 'Italic family ',
+ 'Code mono
', + '', + ].join(''); + + await setEditorHtml(page, html); + + const editor = editorLocator(page); + await expect(editor).toHaveScreenshot('custom-style-fonts-visual.png'); +}); + +test.describe('custom style fonts - toolbar interaction', () => { + test.beforeEach(async ({ page }) => { + await gotoVisualRegression(page); + }); + + test('apply font size then type text', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyFontSize(page, 20); + await editor.pressSequentially('Sized text', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + 'Quote mono family
Sized text
' + ); + }); + + test('clear font size stops sizing new text', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyFontSize(page, 20); + await editor.pressSequentially('Sized', { delay: 80 }); + await clearFontSize(page); + await editor.pressSequentially(' plain', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + 'Sized plain
' + ); + }); + + test('apply font family then type text', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyFontFamily(page, 'Georgia'); + await editor.pressSequentially('Serif text', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + 'Serif text
' + ); + }); + + test('clear font family stops styling new text', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyFontFamily(page, 'Georgia'); + await editor.pressSequentially('Serif', { delay: 80 }); + await clearFontFamily(page); + await editor.pressSequentially(' plain', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + 'Serif plain
' + ); + }); + + test('apply font size and font family together', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyFontSize(page, 24); + await applyFontFamily(page, 'Georgia'); + await editor.pressSequentially('Big serif', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + 'Big serif
' + ); + }); + + test('font size with bold', async ({ page }) => { + const editor = editorLocator(page); + const boldBtn = toolbarButton(page, 'bold'); + await editor.click(); + + await boldBtn.click(); + await applyFontSize(page, 32); + await editor.pressSequentially('Bold big', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + 'Bold big
' + ); + }); + + test('font family with italic', async ({ page }) => { + const editor = editorLocator(page); + const italicBtn = toolbarButton(page, 'italic'); + await editor.click(); + + await italicBtn.click(); + await applyFontFamily(page, 'Georgia'); + await editor.pressSequentially('Italic serif', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + 'Italic serif
' + ); + }); + + test('changing font size on a selection preserves per-run font family', async ({ + page, + }) => { + // Two runs with different families, then re-size the whole line and ensure + // each run keeps its own family (per-range merge, not a flattened span). + await setEditorHtml( + page, + [ + '', + 'One', + 'Two', + '
', + ].join('') + ); + + await focusEnrichedEditable(page); + await page.keyboard.press('ControlOrMeta+a'); + await applyFontSize(page, 20); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + [ + '', + 'One', + 'Two', + '
', + ].join('') + ); + }); + + test('toolbar font-size button shows active size when set', async ({ + page, + }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyFontSize(page, 20); + + await expect(fontSizeButton(page)).toContainText('20'); + await expect(fontSizeButton(page)).toHaveClass(/toolbar-btn--active/); + + // Re-open the picker – the chosen size should be marked as active + await fontSizeButton(page).click(); + await expect(fontSizeOption(page, 20)).toHaveClass( + /toolbar-font-option--active/ + ); + }); + + test('toolbar font-family button shows active family when set', async ({ + page, + }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyFontFamily(page, 'Georgia'); + + // The button shows the family's label ("Serif" maps to "Georgia"). + await expect(fontFamilyButton(page)).toContainText('Serif'); + await expect(fontFamilyButton(page)).toHaveClass(/toolbar-btn--active/); + + // Re-open the picker – the chosen family should be marked as active + await fontFamilyButton(page).click(); + await expect(fontFamilyOption(page, 'Georgia')).toHaveClass( + /toolbar-font-option--active/ + ); + }); +}); diff --git a/android/src/main/java/com/swmansion/enriched/common/ColorUtils.kt b/android/src/main/java/com/swmansion/enriched/common/ColorUtils.kt new file mode 100644 index 000000000..a939626b6 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/common/ColorUtils.kt @@ -0,0 +1,14 @@ +package com.swmansion.enriched.common + +import android.graphics.Color + +internal const val DEFAULT_BACKGROUND_OPACITY = 80 + +internal fun withOpacity( + color: Int, + alpha: Int = DEFAULT_BACKGROUND_OPACITY, +): Int { + if (Color.alpha(color) != 255) return color + val a = alpha.coerceIn(0, 255) + return (color and 0x00FFFFFF) or (a shl 24) +} diff --git a/android/src/main/java/com/swmansion/enriched/common/CustomStyle.kt b/android/src/main/java/com/swmansion/enriched/common/CustomStyle.kt new file mode 100644 index 000000000..76e8980ce --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/common/CustomStyle.kt @@ -0,0 +1,14 @@ +package com.swmansion.enriched.common + +data class CustomStyle( + val foregroundColor: Int? = null, + val backgroundColor: Int? = null, + val fontSize: Float? = null, + val fontFamily: String? = null, +) { + fun isEmpty(): Boolean = + foregroundColor == null && + backgroundColor == null && + (fontSize == null || fontSize <= 0f) && + fontFamily.isNullOrBlank() +} diff --git a/android/src/main/java/com/swmansion/enriched/common/EnrichedSpanFlags.kt b/android/src/main/java/com/swmansion/enriched/common/EnrichedSpanFlags.kt index d9a0ddb50..0466bbb0f 100644 --- a/android/src/main/java/com/swmansion/enriched/common/EnrichedSpanFlags.kt +++ b/android/src/main/java/com/swmansion/enriched/common/EnrichedSpanFlags.kt @@ -2,6 +2,7 @@ package com.swmansion.enriched.common import android.text.Spannable import com.swmansion.enriched.common.spans.EnrichedAlignmentSpan +import com.swmansion.enriched.common.spans.EnrichedCustomStyleSpan import com.swmansion.enriched.common.spans.interfaces.EnrichedInlineSpan // Higher priority spans are processed first, so styles with lower priorities are painted on top of previously applied styles. @@ -10,7 +11,8 @@ import com.swmansion.enriched.common.spans.interfaces.EnrichedInlineSpan object EnrichedSpanFlags { private const val ALIGNMENT_SPAN_PRIORITY = 0 private const val INLINE_SPAN_PRIORITY = 1 - private const val PARAGRAPH_SPAN_PRIORITY = 2 + private const val CUSTOM_STYLE_SPAN_PRIORITY = 2 + private const val PARAGRAPH_SPAN_PRIORITY = 3 @JvmStatic @JvmOverloads @@ -21,6 +23,7 @@ object EnrichedSpanFlags { val priority = when (span) { is EnrichedAlignmentSpan -> ALIGNMENT_SPAN_PRIORITY + is EnrichedCustomStyleSpan -> CUSTOM_STYLE_SPAN_PRIORITY is EnrichedInlineSpan -> INLINE_SPAN_PRIORITY else -> PARAGRAPH_SPAN_PRIORITY } diff --git a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedColorParser.kt b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedColorParser.kt new file mode 100644 index 000000000..fdac22e87 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedColorParser.kt @@ -0,0 +1,273 @@ +package com.swmansion.enriched.common.parser + +import android.graphics.Color +import androidx.core.graphics.toColorInt +import kotlin.math.roundToInt + +object EnrichedColorParser { + private val CSS_NAMED_COLORS = + mapOf( + "aliceblue" to "#F0F8FFFF", + "antiquewhite" to "#FAEBD7FF", + "aqua" to "#00FFFFFF", + "aquamarine" to "#7FFFD4FF", + "azure" to "#F0FFFFFF", + "beige" to "#F5F5DCFF", + "bisque" to "#FFE4C4FF", + "black" to "#000000FF", + "blanchedalmond" to "#FFEBCDFF", + "blue" to "#0000FFFF", + "blueviolet" to "#8A2BE2FF", + "brown" to "#A52A2AFF", + "burlywood" to "#DEB887FF", + "cadetblue" to "#5F9EA0FF", + "chartreuse" to "#7FFF00FF", + "chocolate" to "#D2691EFF", + "coral" to "#FF7F50FF", + "cornflowerblue" to "#6495EDFF", + "cornsilk" to "#FFF8DCFF", + "crimson" to "#DC143CFF", + "cyan" to "#00FFFFFF", + "darkblue" to "#00008BFF", + "darkcyan" to "#008B8BFF", + "darkgoldenrod" to "#B8860BFF", + "darkgray" to "#A9A9A9FF", + "darkgrey" to "#A9A9A9FF", + "darkgreen" to "#006400FF", + "darkkhaki" to "#BDB76BFF", + "darkmagenta" to "#8B008BFF", + "darkolivegreen" to "#556B2FFF", + "darkorange" to "#FF8C00FF", + "darkorchid" to "#9932CCFF", + "darkred" to "#8B0000FF", + "darksalmon" to "#E9967AFF", + "darkseagreen" to "#8FBC8FFF", + "darkslateblue" to "#483D8BFF", + "darkslategray" to "#2F4F4FFF", + "darkslategrey" to "#2F4F4FFF", + "darkturquoise" to "#00CED1FF", + "darkviolet" to "#9400D3FF", + "deeppink" to "#FF1493FF", + "deepskyblue" to "#00BFFFFF", + "dimgray" to "#696969FF", + "dimgrey" to "#696969FF", + "dodgerblue" to "#1E90FFFF", + "firebrick" to "#B22222FF", + "floralwhite" to "#FFFAF0FF", + "forestgreen" to "#228B22FF", + "fuchsia" to "#FF00FFFF", + "gainsboro" to "#DCDCDCFF", + "ghostwhite" to "#F8F8FFFF", + "gold" to "#FFD700FF", + "goldenrod" to "#DAA520FF", + "gray" to "#808080FF", + "grey" to "#808080FF", + "green" to "#008000FF", + "greenyellow" to "#ADFF2FFF", + "honeydew" to "#F0FFF0FF", + "hotpink" to "#FF69B4FF", + "indianred" to "#CD5C5CFF", + "indigo" to "#4B0082FF", + "ivory" to "#FFFFF0FF", + "khaki" to "#F0E68CFF", + "lavender" to "#E6E6FAFF", + "lavenderblush" to "#FFF0F5FF", + "lawngreen" to "#7CFC00FF", + "lemonchiffon" to "#FFFACDFF", + "lightblue" to "#ADD8E6FF", + "lightcoral" to "#F08080FF", + "lightcyan" to "#E0FFFFFF", + "lightgoldenrodyellow" to "#FAFAD2FF", + "lightgray" to "#D3D3D3FF", + "lightgrey" to "#D3D3D3FF", + "lightgreen" to "#90EE90FF", + "lightpink" to "#FFB6C1FF", + "lightsalmon" to "#FFA07AFF", + "lightseagreen" to "#20B2AAFF", + "lightskyblue" to "#87CEFAFF", + "lightslategray" to "#778899FF", + "lightslategrey" to "#778899FF", + "lightsteelblue" to "#B0C4DEFF", + "lightyellow" to "#FFFFE0FF", + "lime" to "#00FF00FF", + "limegreen" to "#32CD32FF", + "linen" to "#FAF0E6FF", + "magenta" to "#FF00FFFF", + "maroon" to "#800000FF", + "mediumaquamarine" to "#66CDAAFF", + "mediumblue" to "#0000CDFF", + "mediumorchid" to "#BA55D3FF", + "mediumpurple" to "#9370D8FF", + "mediumseagreen" to "#3CB371FF", + "mediumslateblue" to "#7B68EEFF", + "mediumspringgreen" to "#00FA9AFF", + "mediumturquoise" to "#48D1CCFF", + "mediumvioletred" to "#C71585FF", + "midnightblue" to "#191970FF", + "mintcream" to "#F5FFFAFF", + "mistyrose" to "#FFE4E1FF", + "moccasin" to "#FFE4B5FF", + "navajowhite" to "#FFDEADFF", + "navy" to "#000080FF", + "oldlace" to "#FDF5E6FF", + "olive" to "#808000FF", + "olivedrab" to "#6B8E23FF", + "orange" to "#FFA500FF", + "orangered" to "#FF4500FF", + "orchid" to "#DA70D6FF", + "palegoldenrod" to "#EEE8AAFF", + "palegreen" to "#98FB98FF", + "paleturquoise" to "#AFEEEEFF", + "palevioletred" to "#D87093FF", + "papayawhip" to "#FFEFD5FF", + "peachpuff" to "#FFDAB9FF", + "peru" to "#CD853FFF", + "pink" to "#FFC0CBFF", + "plum" to "#DDA0DDFF", + "powderblue" to "#B0E0E6FF", + "purple" to "#800080FF", + "rebeccapurple" to "#663399FF", + "red" to "#FF0000FF", + "rosybrown" to "#BC8F8FFF", + "royalblue" to "#4169E1FF", + "saddlebrown" to "#8B4513FF", + "salmon" to "#FA8072FF", + "sandybrown" to "#F4A460FF", + "seagreen" to "#2E8B57FF", + "seashell" to "#FFF5EEFF", + "sienna" to "#A0522DFF", + "silver" to "#C0C0C0FF", + "skyblue" to "#87CEEBFF", + "slateblue" to "#6A5ACDFF", + "slategray" to "#708090FF", + "slategrey" to "#708090FF", + "snow" to "#FFFAFAFF", + "springgreen" to "#00FF7FFF", + "steelblue" to "#4682B4FF", + "tan" to "#D2B48CFF", + "teal" to "#008080FF", + "thistle" to "#D8BFD8FF", + "tomato" to "#FF6347FF", + "turquoise" to "#40E0D0FF", + "violet" to "#EE82EEFF", + "wheat" to "#F5DEB3FF", + "white" to "#FFFFFFFF", + "whitesmoke" to "#F5F5F5FF", + "yellow" to "#FFFF00FF", + "yellowgreen" to "#9ACD32FF", + ) + + @JvmStatic + fun parseCssColor(value: String?): Int? { + if (value.isNullOrBlank()) return null + + var str = value.trim().lowercase() + + // Handle Named Colors + if (CSS_NAMED_COLORS.containsKey(str)) { + str = CSS_NAMED_COLORS[str]!! + } + + // Handle Hex (#FFF, #FFFFFF, #FFFFFFFF CSS format) + if (str.startsWith("#")) { + val hex = str.substring(1) + return try { + when (hex.length) { + 3 -> { + // #RGB -> #RRGGBB + val expanded = "${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}" + "#$expanded".toColorInt() + } + + 4 -> { + // #RGBA -> #AARRGGBB + val expanded = "${hex[3]}${hex[3]}${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}" + "#$expanded".toColorInt() + } + + 6 -> { + // #RRGGBB + str.toColorInt() + } + + 8 -> { + // #RRGGBBAA -> #AARRGGBB + val aarrggbb = "#${hex.substring(6, 8)}${hex.substring(0, 6)}" + aarrggbb.toColorInt() + } + + else -> { + null + } + } + } catch (_: IllegalArgumentException) { + null + } + } + + // Handle rgb() and rgba() + if (str.startsWith("rgb")) { + return try { + val start = str.indexOf('(') + 1 + val end = str.indexOf(')') + if (start <= 0 || end <= start) return null + + val parts = str.substring(start, end).split(",") + if (parts.size >= 3) { + val r = + parts[0] + .trim() + .toFloat() + .roundToInt() + .coerceIn(0, 255) + val g = + parts[1] + .trim() + .toFloat() + .roundToInt() + .coerceIn(0, 255) + val b = + parts[2] + .trim() + .toFloat() + .roundToInt() + .coerceIn(0, 255) + + val a = + if (parts.size == 4) { + (parts[3].trim().toFloat() * 255f).roundToInt().coerceIn(0, 255) + } else { + 255 + } + + Color.argb(a, r, g, b) + } else { + null + } + } catch (_: Exception) { + null + } + } + + // Catch any native Android colors if missed + return try { + str.toColorInt() + } catch (_: IllegalArgumentException) { + null + } + } + + @JvmStatic + fun colorToHex(color: Int): String { + val alpha = (color ushr 24) and 0xFF + val red = (color shr 16) and 0xFF + val green = (color shr 8) and 0xFF + val blue = color and 0xFF + + return if (alpha == 255) { + String.format("#%02X%02X%02X", red, green, blue) + } else { + String.format("#%02X%02X%02X%02X", red, green, blue, alpha) + } + } +} diff --git a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java index 53d761c38..c951aae05 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,52 @@ 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 || fontFamily.indexOf(',') >= 0) { + cssProps.append("font-family: '").append(fontFamily).append("';"); + } else { + cssProps.append("font-family: ").append(fontFamily).append(";"); + } + } + out.append("", ">") + .replace("\"", """)) + .append("\">"); + } else { + out.append(""); + } + } } withinStyle(out, text, i, next); for (int j = style.length - 1; j >= 0; j--) { @@ -369,6 +417,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 +458,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 HtmlToSpannedConverter123
\n456
`. */ +static bool is_layout_whitespace(GumboNode *node) { + if (node->type != GUMBO_NODE_WHITESPACE) + return false; + char buf[64]; + const char *parent_name = get_tag_name(node->parent, buf, sizeof(buf)); + return parent_name && + (has_block_or_bq_child(node->parent) || + (is_table_tag(parent_name) && !is_table_text_tag(parent_name))); +} + /** * Flush buffered inline content as a. Inter-block whitespace (newlines / * spaces between block tags in pretty-printed HTML) is discarded so it does @@ -584,20 +719,22 @@ static bool flush_inline_p(buffer_t *ib, buffer_t *out, return emitted; } -static void flatten_bq_children(GumboNode *node, buffer_t *ib, buffer_t *out) { +static void flatten_bq_children(GumboNode *node, buffer_t *ib, buffer_t *out, + const custom_style_t *inherited) { if (!is_element(node)) return; GumboVector *children = &node->v.element.children; for (unsigned int i = 0; i < children->length; i++) { - flatten_bq_node(children->data[i], ib, out); + flatten_bq_node(children->data[i], ib, out, inherited); } } -static void flatten_bq_node(GumboNode *node, buffer_t *ib, buffer_t *out) { +static void flatten_bq_node(GumboNode *node, buffer_t *ib, buffer_t *out, + const custom_style_t *inherited) { if (!node) return; if (is_text(node)) { - walk_node(node, ib); + walk_node(node, ib, inherited); return; } if (!is_element(node)) { @@ -613,12 +750,12 @@ static void flatten_bq_node(GumboNode *node, buffer_t *ib, buffer_t *out) { } if (is_block_producing(node) || is_blockquote_node(node)) { flush_inline_p(ib, out, NULL); - flatten_bq_children(node, ib, out); + flatten_bq_children(node, ib, out, inherited); // The flattened block becomes a
; carry over its text-align (if any). flush_inline_p(ib, out, &node->v.element); return; } - walk_node(node, ib); + walk_node(node, ib, inherited); } /* ------------------------------------------------------------------ */ @@ -632,6 +769,7 @@ typedef struct { int *nested_count; int max_nested; bool has_emitted; + const custom_style_t *inherited; } li_ctx_t; static void flatten_li_node(GumboNode *node, buffer_t *ib, buffer_t *out, @@ -670,7 +808,7 @@ static void flatten_li_node(GumboNode *node, buffer_t *ib, buffer_t *out, if (!node) return; if (is_text(node)) { - walk_node(node, ib); + walk_node(node, ib, ctx->inherited); return; } if (!is_element(node)) { @@ -705,14 +843,15 @@ static void flatten_li_node(GumboNode *node, buffer_t *ib, buffer_t *out, flush_li_buffer(ib, out, ctx); return; } - walk_node(node, ib); + walk_node(node, ib, ctx->inherited); } /* ------------------------------------------------------------------ */ /* walk_children — the main child-iteration driver */ /* ------------------------------------------------------------------ */ -static void walk_children(GumboNode *node, buffer_t *out) { +static void walk_children(GumboNode *node, buffer_t *out, + const custom_style_t *inherited) { if (!is_element(node)) return; @@ -734,7 +873,7 @@ static void walk_children(GumboNode *node, buffer_t *out) { /* Flatten list-inside-list */ if (parent_is_list && is_list_node(child)) { - walk_children(child, out); + walk_children(child, out, inherited); i++; continue; } @@ -744,7 +883,7 @@ static void walk_children(GumboNode *node, buffer_t *out) { buffer_append_str(out, "
"); buffer_t bq_ib = buffer_create(64); while (i < children->length && is_blockquote_node(children->data[i])) { - flatten_bq_children(children->data[i], &bq_ib, out); + flatten_bq_children(children->data[i], &bq_ib, out, inherited); i++; } flush_inline_p(&bq_ib, out, NULL); @@ -770,11 +909,11 @@ static void walk_children(GumboNode *node, buffer_t *out) { /* Transparent inline wrapper for block/bq children */ if (is_element(child) && has_block_or_bq_child(child)) { flush_inline_p(&ib, out, NULL); - walk_children(child, out); + walk_children(child, out, inherited); i++; continue; } - walk_node(child, &ib); + walk_node(child, &ib, inherited); i++; } flush_inline_p(&ib, out, NULL); @@ -782,7 +921,7 @@ static void walk_children(GumboNode *node, buffer_t *out) { continue; } - walk_node(child, out); + walk_node(child, out, inherited); i++; } } @@ -791,7 +930,8 @@ static void walk_children(GumboNode *node, buffer_t *out) { /* walk_node — process a single DOM node */ /* ------------------------------------------------------------------ */ -static void walk_node(GumboNode *node, buffer_t *out) { +static void walk_node(GumboNode *node, buffer_t *out, + const custom_style_t *inherited) { if (!node) return; @@ -800,6 +940,9 @@ static void walk_node(GumboNode *node, buffer_t *out) { const char *text_raw = node->v.text.text; if (text_raw) { size_t text_len = strlen(text_raw); + bool wrap = inherited && !is_layout_whitespace(node); + if (wrap) + emit_custom_style_open(out, *inherited); for (size_t i = 0; i < text_len; i++) { char c = text_raw[i]; switch (c) { @@ -817,19 +960,21 @@ static void walk_node(GumboNode *node, buffer_t *out) { break; } } + if (wrap) + buffer_append_str(out, ""); } return; } if (!is_element(node)) { - walk_children(node, out); + walk_children(node, out, inherited); return; } GumboElement *el = &node->v.element; char name_buf[64]; if (!get_tag_name(node, name_buf, sizeof(name_buf))) { - walk_children(node, out); + walk_children(node, out, inherited); return; } @@ -841,7 +986,7 @@ static void walk_node(GumboNode *node, buffer_t *out) { /* Google Docs wrapper */ if (is_google_docs_wrapper(el, name_buf)) { - walk_children(node, out); + walk_children(node, out, inherited); return; } @@ -856,9 +1001,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); + + // own properties override the inherited ones + custom_style_t own = s.custom; + custom_style_t style = {{NULL, 0}, {NULL, 0}, {NULL, 0}, {NULL, 0}}; + if (inherited) + style = *inherited; + if (own.fg.len) + style.fg = own.fg; + if (own.bg.len) + style.bg = own.bg; + if (own.font_size.len) + style.font_size = own.font_size; + if (own.font_family.len) + style.font_family = own.font_family; + + const custom_style_t *children_style = inherited; + bool wrap = false; + if (!custom_style_is_empty(own)) { + // if there are no nested spans deeper, we can emit a wrapper immediately, + // otherwise we hold off with that and we'll emit span tags deeper, after flattening + if (has_styled_span_descendant(node)) { + children_style = &style; + } else { + wrap = true; + children_style = NULL; + } + } + if (wrap) { + emit_custom_style_open(out, style); + } + + // handle inner formatting (, , etc.) and children emit_styles_open(out, s); - walk_children(node, out); + walk_children(node, out, children_style); emit_styles_close(out, s); + + // close the wrapper span + if (wrap) { + buffer_append_str(out, ""); + } + return; } @@ -887,7 +1070,7 @@ static void walk_node(GumboNode *node, buffer_t *out) { buffer_clear(&pb); continue; } - walk_node(dc, &pb); + walk_node(dc, &pb, inherited); } if (pb.len > 0) { buffer_append_str(out, "
"); @@ -899,20 +1082,16 @@ static void walk_node(GumboNode *node, buffer_t *out) { free(pb.data); } else { emit_styles_open(out, s); - walk_children(node, out); + walk_children(node, out, inherited); emit_styles_close(out, s); } return; } /* --- Table elements --- */ - if (strcmp(name_buf, "table") == 0 || strcmp(name_buf, "thead") == 0 || - strcmp(name_buf, "tbody") == 0 || strcmp(name_buf, "tfoot") == 0 || - strcmp(name_buf, "tr") == 0 || strcmp(name_buf, "td") == 0 || - strcmp(name_buf, "th") == 0 || strcmp(name_buf, "caption") == 0 || - strcmp(name_buf, "colgroup") == 0 || strcmp(name_buf, "col") == 0) { + if (is_table_tag(name_buf)) { if (strcmp(name_buf, "td") == 0 || strcmp(name_buf, "th") == 0) { - walk_children(node, out); + walk_children(node, out, inherited); /* Check if there's a next sibling element */ GumboNode *parent = node->parent; if (parent && is_element(parent)) { @@ -932,7 +1111,7 @@ static void walk_node(GumboNode *node, buffer_t *out) { } } else if (strcmp(name_buf, "tr") == 0) { buffer_t row = buffer_create(64); - walk_children(node, &row); + walk_children(node, &row, inherited); if (row.len > 0) { buffer_append_str(out, "
");
buffer_append(out, row.data, row.len);
@@ -940,7 +1119,7 @@ static void walk_node(GumboNode *node, buffer_t *out) {
}
free(row.data);
} else {
- walk_children(node, out);
+ walk_children(node, out, inherited);
}
return;
}
@@ -949,7 +1128,7 @@ static void walk_node(GumboNode *node, buffer_t *out) {
switch (cls) {
case TAG_CLASS_PASS:
case TAG_CLASS_SKIP:
- walk_children(node, out);
+ walk_children(node, out, inherited);
break;
case TAG_CLASS_SELF_CLOSING:
@@ -970,7 +1149,8 @@ static void walk_node(GumboNode *node, buffer_t *out) {
GumboNode *nested_lists[16];
int nested_count = 0;
buffer_t li_ib = buffer_create(64);
- li_ctx_t ctx = {el, es, nested_lists, &nested_count, 16};
+ li_ctx_t ctx = {el, es, nested_lists, &nested_count,
+ 16, false, inherited};
flatten_li_children(node, &li_ib, out, &ctx);
flush_li_buffer(&li_ib, out, &ctx);
@@ -983,7 +1163,7 @@ static void walk_node(GumboNode *node, buffer_t *out) {
free(li_ib.data);
for (int k = 0; k < nested_count; k++)
- walk_children(nested_lists[k], out);
+ walk_children(nested_lists[k], out, inherited);
break;
}
@@ -993,7 +1173,7 @@ static void walk_node(GumboNode *node, buffer_t *out) {
buffer_append_str(out, " ");
- walk_children(node, out);
+ walk_children(node, out, inherited);
if (wrap)
buffer_append_str(out, " A B A B A \nB\n A B "
+ "C AB tags the same way lists use A B A B A \nB\n A B C AB 123 456 . Inter-block whitespace (newlines /
* spaces between block tags in pretty-printed HTML) is discarded so it does
@@ -382,21 +456,22 @@ function flushInlineP(
function flattenBqChildren(
node: Element,
ib: { buf: string },
- out: { buf: string }
+ out: { buf: string },
+ inherited: CustomStyle | null
): void {
for (const child of Array.from(node.childNodes)) {
- flattenBqNode(child, ib, out);
+ flattenBqNode(child, ib, out, inherited);
}
}
function flattenBqNode(
node: Node,
ib: { buf: string },
- out: { buf: string }
+ out: { buf: string },
+ inherited: CustomStyle | null
): void {
if (isText(node)) {
- const t = node.textContent ?? '';
- ib.buf += escapeText(t);
+ walkNode(node, ib, inherited);
return;
}
if (!isElement(node)) return;
@@ -410,12 +485,12 @@ function flattenBqNode(
}
if (isBlockProducing(node) || isBlockquoteNode(node)) {
flushInlineP(ib, out);
- flattenBqChildren(node, ib, out);
+ flattenBqChildren(node, ib, out, inherited);
// The flattened block becomes a ; carry over its text-align (if any).
flushInlineP(ib, out, emitAlignment(node, 'p'));
return;
}
- walkNode(node, ib);
+ walkNode(node, ib, inherited);
}
// --- List item content flattening ---
@@ -425,6 +500,7 @@ type LiCtx = {
styles: CssStyles;
nestedLists: Element[];
hasEmitted: boolean;
+ inherited: CustomStyle | null;
};
function flushLiBuffer(
@@ -465,7 +541,7 @@ function flattenLiNode(
ctx: LiCtx
): void {
if (isText(node)) {
- ib.buf += escapeText(node.textContent ?? '');
+ walkNode(node, ib, ctx.inherited);
return;
}
if (!isElement(node)) return;
@@ -492,12 +568,16 @@ function flattenLiNode(
flushLiBuffer(ib, out, ctx);
return;
}
- walkNode(node, ib);
+ walkNode(node, ib, ctx.inherited);
}
// --- Main walker ---
-function walkChildren(node: Element, out: { buf: string }): void {
+function walkChildren(
+ node: Element,
+ out: { buf: string },
+ inherited: CustomStyle | null
+): void {
const children = Array.from(node.childNodes);
const parentIsList = isListNode(node);
@@ -515,7 +595,7 @@ function walkChildren(node: Element, out: { buf: string }): void {
// Flatten list-inside-list
if (parentIsList && isElement(child) && isListNode(child)) {
- walkChildren(child, out);
+ walkChildren(child, out, inherited);
i++;
continue;
}
@@ -527,7 +607,7 @@ function walkChildren(node: Element, out: { buf: string }): void {
while (i < children.length) {
const cur = children[i];
if (!cur || !isElement(cur) || !isBlockquoteNode(cur)) break;
- flattenBqChildren(cur, bqIb, out);
+ flattenBqChildren(cur, bqIb, out, inherited);
i++;
}
flushInlineP(bqIb, out);
@@ -562,25 +642,33 @@ function walkChildren(node: Element, out: { buf: string }): void {
// Transparent inline wrapper for block/bq children
if (isElement(cur) && hasBlockOrBqChild(cur)) {
flushInlineP(ib, out);
- walkChildren(cur, out);
+ walkChildren(cur, out, inherited);
i++;
continue;
}
- walkNode(cur, ib);
+ walkNode(cur, ib, inherited);
i++;
}
flushInlineP(ib, out);
continue;
}
- walkNode(child, out);
+ walkNode(child, out, inherited);
i++;
}
}
-function walkNode(node: Node, out: { buf: string }): void {
+function walkNode(
+ node: Node,
+ out: { buf: string },
+ inherited: CustomStyle | null
+): void {
if (isText(node)) {
- out.buf += escapeText(node.textContent ?? '');
+ const text = node.textContent ?? '';
+ const wrap = !!inherited && !isLayoutWhitespace(node);
+ if (wrap) out.buf += emitCustomStyleOpen(inherited);
+ out.buf += escapeText(text);
+ if (wrap) out.buf += ' ${emitStylesOpen(s)}${pb.buf}${emitStylesClose(s)} ${row.buf} ';
- walkChildren(node, out);
+ walkChildren(node, out, inherited);
if (wrap) out.buf += '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"
+ "span>"),
+ "x");
+
+ // Color + Multiple inline styles
+ EXPECT_EQ(GumboParser::normalizeHtml(
+ "x"),
+ "x");
+
+ // Mix of colors and inline styles
+ EXPECT_EQ(
+ GumboParser::normalizeHtml(
+ "x"),
+ "x");
+
+ // Quotes can't break out of the style attribute
+ EXPECT_EQ(GumboParser::normalizeHtml(
+ "x"),
+ "x");
+
+ // Font size
+ EXPECT_EQ(
+ GumboParser::normalizeHtml("x"),
+ "x");
+
+ // Font family: the whole stack is passed through, picking the family is up
+ // to the parsers
+ EXPECT_EQ(GumboParser::normalizeHtml(
+ "x"),
+ "x");
+ EXPECT_EQ(
+ GumboParser::normalizeHtml(
+ "x"),
+ "x");
+ EXPECT_EQ(GumboParser::normalizeHtml(
+ "x"),
+ "x");
+
+ // All custom styles combined, with inline styles
+ EXPECT_EQ(GumboParser::normalizeHtml(
+ "x"
+ "span>"),
+ "x");
+
+ // `&` and `"` are escaped
+ EXPECT_EQ(GumboParser::normalizeHtml(
+ "x"),
+ "x");
+ EXPECT_EQ(
+ GumboParser::normalizeHtml("x"),
+ "x");
}
TEST(GumboParserTest, EnrichedTagRemappings) {
@@ -625,3 +723,117 @@ TEST(GumboParserTest, InterBlockWhitespace) {
EXPECT_EQ(GumboParser::normalizeHtml("hello world"),
"hello world");
}
+
+TEST(GumboParserTest, NestedSpans) {
+ // Child overrides the parent
+ EXPECT_EQ(GumboParser::normalizeHtml(
+ "ABC"),
+ "ABC");
+
+ // Child inherits properties it doesn't set
+ EXPECT_EQ(GumboParser::normalizeHtml(
+ "AB"),
+ "AB");
+
+ // Unstyled spans are transparent
+ EXPECT_EQ(GumboParser::normalizeHtml(
+ "ABC"),
+ "ABC");
+
+ // Styles are pushed down through other inline tags
+ EXPECT_EQ(GumboParser::normalizeHtml(
+ "AB"),
+ "AB");
+
+ // A wrapper inside a pushed-down span merges the inherited style, and its
+ // text isn't wrapped again
+ EXPECT_EQ(GumboParser::normalizeHtml(
+ "ABCD"),
+ "ABCD");
+
+ // Styles are inherited across multiple levels
+ EXPECT_EQ(
+ GumboParser::normalizeHtml(
+ "ABC"),
+ "ABC");
+
+ // Transparent background doesn't override the inherited one
+ EXPECT_EQ(GumboParser::normalizeHtml(
+ "AB"),
+ "AB");
+
+ // `inherit` doesn't override the inherited value
+ EXPECT_EQ(
+ GumboParser::normalizeHtml("AB"),
+ "AB");
+
+ // Transparent background alone is treated as no style
+ EXPECT_EQ(GumboParser::normalizeHtml(
+ "A"),
+ "A");
+
+ // Whitespace between blocks is layout and doesn't become a paragraph
+ EXPECT_EQ(GumboParser::normalizeHtml(
+ "\n
"),
+ "\n \nB \n
"),
+ "B C
"),
+ "
");
+ EXPECT_EQ(GumboParser::normalizeHtml(
+ "AB
"),
+ "
");
+}
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.
+
+\n
',
+ '\n \nB \n
',
+ 'B C
',
+ '
',
+ ],
+ [
+ 'AB
',
+ '
',
+ ],
+ ])('%s → %s', (input, expected) => {
+ expect(normalizeHtml(input)).toBe(expected);
+ });
+ });
});
diff --git a/src/web/__tests__/normalizeFontFamily.test.ts b/src/web/__tests__/normalizeFontFamily.test.ts
new file mode 100644
index 000000000..67a55960e
--- /dev/null
+++ b/src/web/__tests__/normalizeFontFamily.test.ts
@@ -0,0 +1,20 @@
+import { normalizeFontFamily } from '../formats/EnrichedCustomStyle';
+
+describe('normalizeFontFamily', () => {
+ it.each([
+ // first family of the stack
+ ['Roboto, sans-serif', 'Roboto'],
+ ['"Open Sans", Arial', 'Open Sans'],
+ ["'Open Sans'", 'Open Sans'],
+ // commas inside quotes are part of the family name
+ ['"SWM, Inc", Arial', 'SWM, Inc'],
+ ["'SWM,Inc', Arial", 'SWM,Inc'],
+ // an empty first family is no family
+ [', Arial', null],
+ ["'", null],
+ ['', null],
+ [null, null],
+ ])('%p → %p', (input, expected) => {
+ expect(normalizeFontFamily(input)).toBe(expected);
+ });
+});
diff --git a/src/web/formats/EnrichedCustomStyle.ts b/src/web/formats/EnrichedCustomStyle.ts
new file mode 100644
index 000000000..fd524c977
--- /dev/null
+++ b/src/web/formats/EnrichedCustomStyle.ts
@@ -0,0 +1,271 @@
+import { Mark } from '@tiptap/core';
+import type { Attrs } from '@tiptap/pm/model';
+import { withBaseFontFamilyFallback } from '../styleConversion/enrichedStyleToCSSVariables';
+import { deleteCharacterBackward } from '../utils/deleteCharacterBackward';
+
+type CustomStyleAttrs = {
+ foregroundColor?: string | null;
+ backgroundColor?: string | null;
+ fontSize?: number | null;
+ fontFamily?: string | null;
+};
+
+function firstInFontFamilyString(value: string): string {
+ let quote = '';
+ for (let i = 0; i < value.length; i++) {
+ const c = value[i];
+ if (quote) {
+ if (c === quote) quote = '';
+ } else if (c === "'" || c === '"') {
+ quote = c;
+ } else if (c === ',') {
+ return value.slice(0, i);
+ }
+ }
+ return value;
+}
+
+export function normalizeFontFamily(
+ value: string | null | undefined
+): string | null {
+ if (!value) return null;
+
+ let fontFamily = firstInFontFamilyString(value.trim()).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 or a comma, 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`;
});
+ // Find all style="..." attributes in the HTML
+ html = html.replace(/style="([^"]*)"/gi, (_, styleString: string) => {
+ let updatedStyle = styleString;
+
+ // Convert color: