From 1db3b33ff2cd94cf8cb3b960a7af48c076c676a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Mon, 22 Jun 2026 13:40:33 +0200 Subject: [PATCH 1/6] feat(web): custom style --- apps/example-web/src/components/Toolbar.css | 62 ++++++++ apps/example-web/src/components/Toolbar.tsx | 165 ++++++++++++++++++-- src/web/EnrichedTextInput.tsx | 24 ++- src/web/formats/EnrichedCustomStyle.ts | 94 +++++++++++ src/web/normalization/htmlNormalizer.ts | 23 ++- src/web/useOnChangeState.ts | 30 ++-- 6 files changed, 368 insertions(+), 30 deletions(-) create mode 100644 src/web/formats/EnrichedCustomStyle.ts diff --git a/apps/example-web/src/components/Toolbar.css b/apps/example-web/src/components/Toolbar.css index 7b86fb8d..2186d24d 100644 --- a/apps/example-web/src/components/Toolbar.css +++ b/apps/example-web/src/components/Toolbar.css @@ -77,3 +77,65 @@ .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; +} + +.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; +} diff --git a/apps/example-web/src/components/Toolbar.tsx b/apps/example-web/src/components/Toolbar.tsx index 42241fae..ae8d6a42 100644 --- a/apps/example-web/src/components/Toolbar.tsx +++ b/apps/example-web/src/components/Toolbar.tsx @@ -3,9 +3,29 @@ 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 = [ + 'red', + '#808080', + '#FF0000', + '#FF6600', + '#FFFF00', + '#00FF00', + '#008000', + '#00FFFF', + '#0000FF', + '#800080', + '#FF00FF', + '#FF69B4', + '#A52A2A', + '#FFA500', + '#ADD8E6', +]; + +type OpenPicker = 'text-color' | 'bg-color' | null; + interface ToolbarProps { editorRef: RefObject; state: OnChangeStateEvent | null; @@ -61,6 +81,27 @@ 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 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 toolbarItems = [ { @@ -203,23 +244,115 @@ export function Toolbar({ }[]; return ( -
-
- {toolbarItems.map((item) => ( - { - item.onPress(editorRef.current); +
+
+
+ {toolbarItems.map((item) => ( + { + item.onPress(editorRef.current); + }} + /> + ))} + + +
+ - ); } diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx index 384459cc..84b65de3 100644 --- a/src/web/EnrichedTextInput.tsx +++ b/src/web/EnrichedTextInput.tsx @@ -60,6 +60,7 @@ import { EnrichedUnorderedList } from './formats/EnrichedUnorderedList'; import { EnrichedOrderedList } from './formats/EnrichedOrderedList'; import { EnrichedCheckboxItem } from './formats/EnrichedCheckboxItem'; import { EnrichedCheckboxList } from './formats/EnrichedCheckboxList'; +import { EnrichedCustomStyle } from './formats/EnrichedCustomStyle'; import { StripBoldInStyledHeadingsPlugin } from './pmPlugins/StripBoldInStyledHeadingsPlugin'; import { StrictMarksPlugin } from './pmPlugins/StrictMarksPlugin'; import { MergeAdjacentSameKindBlocksPlugin } from './pmPlugins/MergeAdjacentSameKindBlocksPlugin'; @@ -215,6 +216,7 @@ export const EnrichedTextInput = ({ EnrichedUnorderedList, EnrichedOrderedList, EnrichedCheckboxList, + EnrichedCustomStyle, StripMarksInCodeBlockPlugin, StripMarksOnImagePlugin, StripBoldInStyledHeadingsPlugin.configure({ @@ -368,7 +370,27 @@ export const EnrichedTextInput = ({ measureLayout: () => {}, setNativeProps: () => {}, setTextAlignment: () => {}, - setStyle: () => {}, + setStyle: (customStyle) => { + const current = editor.getAttributes('customStyle'); + + const resolvedColor = + 'foregroundColor' in customStyle + ? (customStyle.foregroundColor ?? null) + : current.foregroundColor; + const resolvedBg = + 'backgroundColor' in customStyle + ? (customStyle.backgroundColor ?? null) + : (current.backgroundColor ?? null); + + if (!resolvedColor && !resolvedBg) { + editor.commands.unsetCustomStyle(); + } else { + editor.commands.setCustomStyle({ + foregroundColor: resolvedColor, + backgroundColor: resolvedBg, + }); + } + }, }), [editor] ); diff --git a/src/web/formats/EnrichedCustomStyle.ts b/src/web/formats/EnrichedCustomStyle.ts new file mode 100644 index 00000000..7602111b --- /dev/null +++ b/src/web/formats/EnrichedCustomStyle.ts @@ -0,0 +1,94 @@ +import { Mark } from '@tiptap/core'; + +declare module '@tiptap/core' { + interface Commands { + customStyle: { + setCustomStyle: (attrs: { + foregroundColor?: string | null; + backgroundColor?: string | null; + }) => ReturnType; + unsetCustomStyle: () => 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, + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'span', + getAttrs: (el: HTMLElement) => { + if (!el.style.color && !el.style.backgroundColor) { + return false; + } + // let addAttributes handle the actual parsing + return null; + }, + }, + ]; + }, + + renderHTML({ mark }) { + const parts: string[] = []; + if (mark.attrs.foregroundColor) { + parts.push(`color: ${mark.attrs.foregroundColor}`); + } + if (mark.attrs.backgroundColor) { + parts.push(`background-color: ${mark.attrs.backgroundColor}`); + } + return ['span', { style: parts.join('; ') }, 0]; + }, + + addCommands() { + return { + setCustomStyle: + (attrs) => + ({ chain, editor }) => { + const current = editor.getAttributes('customStyle'); + const resolvedColor = + 'foregroundColor' in attrs + ? attrs.foregroundColor + : current.foregroundColor; + const resolvedBg = + 'backgroundColor' in attrs + ? attrs.backgroundColor + : current.backgroundColor; + + if (!resolvedColor && !resolvedBg) { + return chain().unsetMark('customStyle').run(); + } + + return chain() + .setMark('customStyle', { + foregroundColor: resolvedColor ?? null, + backgroundColor: resolvedBg ?? null, + }) + .run(); + }, + + unsetCustomStyle: + () => + ({ chain }) => + chain().unsetMark('customStyle').run(), + }; + }, +}); diff --git a/src/web/normalization/htmlNormalizer.ts b/src/web/normalization/htmlNormalizer.ts index be34e44f..de4d9838 100644 --- a/src/web/normalization/htmlNormalizer.ts +++ b/src/web/normalization/htmlNormalizer.ts @@ -500,10 +500,31 @@ function walkNode(node: Node, out: { buf: string }): void { // : CSS style → inline tags if (name === 'span') { - const s = parseCssStyle(node.getAttribute('style')); + const rawStyle = node.getAttribute('style') || ''; + const s = parseCssStyle(rawStyle); + + const fg = findCssValue(rawStyle, 'color'); + const bg = findCssValue(rawStyle, 'background-color'); + + // 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 (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/useOnChangeState.ts b/src/web/useOnChangeState.ts index 6f4fcb17..08b52eb7 100644 --- a/src/web/useOnChangeState.ts +++ b/src/web/useOnChangeState.ts @@ -99,24 +99,30 @@ function buildState( }, alignment: 'left', customStyle: { - foregroundColor: '', - backgroundColor: '', + foregroundColor: + editor.getAttributes('customStyle').foregroundColor ?? '', + backgroundColor: + editor.getAttributes('customStyle').backgroundColor ?? '', }, }; } function hashState(state: OnChangeStateEvent): string { - return Object.values(state) - .map((formatState) => - 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}`; } function getFormatHash( From 5d3b8ebd93aabda4caa1013f0d26830a861796b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Mon, 22 Jun 2026 16:19:46 +0200 Subject: [PATCH 2/6] test: add tests for custom style --- .../custom-style-colors-visual.png | Bin 0 -> 18821 bytes .playwright/tests/customStyleColors.spec.ts | 287 ++++++++++++++++++ apps/example-web/src/components/Toolbar.tsx | 2 + 3 files changed, 289 insertions(+) create mode 100644 .playwright/screenshots/custom-style-colors-visual.png create mode 100644 .playwright/tests/customStyleColors.spec.ts diff --git a/.playwright/screenshots/custom-style-colors-visual.png b/.playwright/screenshots/custom-style-colors-visual.png new file mode 100644 index 0000000000000000000000000000000000000000..2b55288e73f8363cf8bb2043cfdfafdc2431cc2d GIT binary patch literal 18821 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

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('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/apps/example-web/src/components/Toolbar.tsx b/apps/example-web/src/components/Toolbar.tsx index ae8d6a42..c70ef528 100644 --- a/apps/example-web/src/components/Toolbar.tsx +++ b/apps/example-web/src/components/Toolbar.tsx @@ -319,6 +319,7 @@ export function Toolbar({