diff --git a/.playwright/screenshots/custom-style-colors-visual.png b/.playwright/screenshots/custom-style-colors-visual.png
new file mode 100644
index 00000000..2b55288e
Binary files /dev/null and b/.playwright/screenshots/custom-style-colors-visual.png differ
diff --git a/.playwright/tests/customStyleColors.spec.ts b/.playwright/tests/customStyleColors.spec.ts
new file mode 100644
index 00000000..0c34b78b
--- /dev/null
+++ b/.playwright/tests/customStyleColors.spec.ts
@@ -0,0 +1,287 @@
+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('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.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..e68f44a8 100644
--- a/apps/example-web/src/components/Toolbar.tsx
+++ b/apps/example-web/src/components/Toolbar.tsx
@@ -3,9 +3,28 @@ 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',
+];
+
+type OpenPicker = 'text-color' | 'bg-color' | null;
+
interface ToolbarProps {
editorRef: RefObject;
state: OnChangeStateEvent | null;
@@ -61,6 +80,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 +243,117 @@ export function Toolbar({
}[];
return (
-
-
- {toolbarItems.map((item) => (
-
{
- item.onPress(editorRef.current);
+
+
+
+ {toolbarItems.map((item) => (
+ {
+ item.onPress(editorRef.current);
+ }}
+ />
+ ))}
+
+
+
+
-
+ {openPicker !== null && (
+
+
+ {COLORS.map((color) => {
+ const isActive =
+ openPicker === 'text-color'
+ ? activeFgColor.toLowerCase() === color.toLowerCase()
+ : activeBgColor.toLowerCase() === color.toLowerCase();
+ return (
+
+ )}
);
}
diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx
index 384459cc..2b810e1c 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,29 @@ 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);
+
+ runFocused(editor, (c) => {
+ if (!resolvedColor && !resolvedBg) {
+ return c.unsetCustomStyle();
+ }
+
+ return c.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/colorNormalizer.ts b/src/web/normalization/colorNormalizer.ts
new file mode 100644
index 00000000..ca043d08
--- /dev/null
+++ b/src/web/normalization/colorNormalizer.ts
@@ -0,0 +1,225 @@
+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;
+}
diff --git a/src/web/normalization/htmlNormalizer.ts b/src/web/normalization/htmlNormalizer.ts
index be34e44f..8c88cbad 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 htmlNode = node as HTMLElement;
const s = parseCssStyle(node.getAttribute('style'));
+
+ const fg = htmlNode.style.color;
+ const bg = htmlNode.style.backgroundColor;
+
+ // 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/normalization/tiptapHtmlNormalizer.ts b/src/web/normalization/tiptapHtmlNormalizer.ts
index 9ebd2c65..1af39be3 100644
--- a/src/web/normalization/tiptapHtmlNormalizer.ts
+++ b/src/web/normalization/tiptapHtmlNormalizer.ts
@@ -2,6 +2,7 @@ import {
checkboxHtmlForTiptap,
checkboxHtmlFromTiptap,
} from './checkboxHtmlNormalizer';
+import { normalizeColorToHex } from './colorNormalizer';
import { normalizeHtml } from './htmlNormalizer';
export function prepareHtmlForTiptap(
@@ -36,5 +37,30 @@ export function normalizeHtmlFromTiptap(html: string): string {
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 5eee201f..66836e1a 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/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(