diff --git a/packages/super-editor/src/core/utilities/cssColorToHex.js b/packages/super-editor/src/core/utilities/cssColorToHex.js
new file mode 100644
index 0000000000..ec75249dc1
--- /dev/null
+++ b/packages/super-editor/src/core/utilities/cssColorToHex.js
@@ -0,0 +1,33 @@
+/**
+ * Converts a CSS color value to hex format (#RRGGBB).
+ * Handles rgb(), rgba(), hex, and returns null for empty input, transparent rgba, invalid rgb values.
+ * Named colors are returned as-is.
+ *
+ * @param {string|null|undefined} cssColor - A CSS color string
+ * @returns {string|null} Normalized color string or null
+ */
+export function cssColorToHex(cssColor) {
+ if (!cssColor) return null;
+ const trimmed = cssColor.trim();
+ if (!trimmed) return null;
+
+ // Already hex — pass through
+ if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(trimmed)) {
+ return trimmed;
+ }
+
+ // Parse rgb(r, g, b) or rgba(r, g, b, a)
+ const rgbMatch = trimmed.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/);
+ if (rgbMatch) {
+ const [, r, g, b, a] = rgbMatch;
+
+ if (a !== undefined && parseFloat(a) === 0) return null;
+ if (Number(r) > 255 || Number(g) > 255 || Number(b) > 255) return null;
+
+ return '#' + [r, g, b].map((c) => Number(c).toString(16).padStart(2, '0')).join('');
+ }
+
+ // Return as-is for other valid formats (named colors, etc.)
+ // Browsers normalize pasted colors to rgb(), so this is a rare fallback
+ return trimmed;
+}
diff --git a/packages/super-editor/src/core/utilities/index.js b/packages/super-editor/src/core/utilities/index.js
index 933277313f..ae4f05fcbe 100644
--- a/packages/super-editor/src/core/utilities/index.js
+++ b/packages/super-editor/src/core/utilities/index.js
@@ -8,3 +8,4 @@ export * from './deleteProps.js';
export * from './parseSizeUnit.js';
export * from './minMax.js';
export * from './clipboardUtils.js';
+export * from './cssColorToHex.js';
diff --git a/packages/super-editor/src/core/utilities/tests/utilities.test.js b/packages/super-editor/src/core/utilities/tests/utilities.test.js
index 9b15499dfe..8cad9bdc07 100644
--- a/packages/super-editor/src/core/utilities/tests/utilities.test.js
+++ b/packages/super-editor/src/core/utilities/tests/utilities.test.js
@@ -12,6 +12,7 @@ import { isRegExp } from '../isRegExp.js';
import { minMax } from '../minMax.js';
import { objectIncludes } from '../objectIncludes.js';
import { parseSizeUnit } from '../parseSizeUnit.js';
+import { cssColorToHex } from '../cssColorToHex.js';
const originalNavigator = global.navigator;
const originalFile = global.File;
@@ -222,4 +223,65 @@ describe('core utilities', () => {
expect(parseSizeUnit('10unknown')).toEqual([10, null]);
});
});
+
+ describe('cssColorToHex', () => {
+ it('returns null for null, undefined, and empty string', () => {
+ expect(cssColorToHex(null)).toBeNull();
+ expect(cssColorToHex(undefined)).toBeNull();
+ expect(cssColorToHex('')).toBeNull();
+ expect(cssColorToHex(' ')).toBeNull();
+ });
+
+ it('passes through 6-digit hex colors', () => {
+ expect(cssColorToHex('#ff0000')).toBe('#ff0000');
+ expect(cssColorToHex('#00FF00')).toBe('#00FF00');
+ });
+
+ it('passes through 3-digit hex colors', () => {
+ expect(cssColorToHex('#f00')).toBe('#f00');
+ });
+
+ it('converts rgb() to hex', () => {
+ expect(cssColorToHex('rgb(255, 0, 0)')).toBe('#ff0000');
+ expect(cssColorToHex('rgb(0, 128, 255)')).toBe('#0080ff');
+ expect(cssColorToHex('rgb(0, 0, 0)')).toBe('#000000');
+ expect(cssColorToHex('rgb(255, 255, 255)')).toBe('#ffffff');
+ });
+
+ it('converts rgba() to hex (ignoring alpha)', () => {
+ expect(cssColorToHex('rgba(255, 0, 0, 0.5)')).toBe('#ff0000');
+ expect(cssColorToHex('rgba(0, 128, 255, 1)')).toBe('#0080ff');
+ });
+
+ it('returns null for fully transparent rgba (alpha 0)', () => {
+ expect(cssColorToHex('rgba(255, 0, 0, 0)')).toBeNull();
+ expect(cssColorToHex('rgba(0, 0, 0, 0.0)')).toBeNull();
+ });
+
+ it('returns null for out-of-range rgb channel values', () => {
+ expect(cssColorToHex('rgb(256, 0, 0)')).toBeNull();
+ expect(cssColorToHex('rgb(0, 300, 0)')).toBeNull();
+ expect(cssColorToHex('rgb(0, 0, 999)')).toBeNull();
+ expect(cssColorToHex('rgba(256, 0, 0, 1)')).toBeNull();
+ });
+
+ it('accepts rgb boundary values', () => {
+ expect(cssColorToHex('rgb(0, 0, 0)')).toBe('#000000');
+ expect(cssColorToHex('rgb(255, 255, 255)')).toBe('#ffffff');
+ });
+
+ it('handles rgb with no spaces', () => {
+ expect(cssColorToHex('rgb(255,0,0)')).toBe('#ff0000');
+ });
+
+ it('returns named colors as-is', () => {
+ expect(cssColorToHex('red')).toBe('red');
+ expect(cssColorToHex('blue')).toBe('blue');
+ });
+
+ it('trims whitespace', () => {
+ expect(cssColorToHex(' #ff0000 ')).toBe('#ff0000');
+ expect(cssColorToHex(' rgb(255, 0, 0) ')).toBe('#ff0000');
+ });
+ });
});
diff --git a/packages/super-editor/src/extensions/color/color.js b/packages/super-editor/src/extensions/color/color.js
index 38fb3f258c..9b1f4ee427 100644
--- a/packages/super-editor/src/extensions/color/color.js
+++ b/packages/super-editor/src/extensions/color/color.js
@@ -1,5 +1,6 @@
// @ts-nocheck
import { Extension } from '@core/index.js';
+import { cssColorToHex } from '@core/utilities/cssColorToHex.js';
/**
* Color value format
@@ -48,7 +49,7 @@ export const Color = Extension.create({
attributes: {
color: {
default: null,
- parseDOM: (el) => el.style.color?.replace(/['"]+/g, ''),
+ parseDOM: (el) => cssColorToHex(el.style.color),
renderDOM: (attrs) => {
if (!attrs.color) return {};
return { style: `color: ${attrs.color}` };
diff --git a/packages/super-editor/src/extensions/highlight/highlight.js b/packages/super-editor/src/extensions/highlight/highlight.js
index 348a60cfec..083a3b5e22 100644
--- a/packages/super-editor/src/extensions/highlight/highlight.js
+++ b/packages/super-editor/src/extensions/highlight/highlight.js
@@ -1,5 +1,6 @@
// @ts-nocheck
import { Mark, Attribute } from '@core/index.js';
+import { cssColorToHex } from '@core/utilities/cssColorToHex.js';
/**
* Configuration options for Highlight
@@ -34,7 +35,7 @@ export const Highlight = Mark.create({
return {
color: {
default: null,
- parseDOM: (element) => element.getAttribute('data-color') || element.style.backgroundColor,
+ parseDOM: (element) => cssColorToHex(element.getAttribute('data-color') || element.style.backgroundColor),
renderDOM: (attributes) => {
if (!attributes.color) {
return {};
@@ -49,7 +50,18 @@ export const Highlight = Mark.create({
},
parseDOM() {
- return [{ tag: 'mark' }];
+ return [
+ { tag: 'mark' },
+ {
+ style: 'background-color',
+ getAttrs: (value) => {
+ if (!value || value === 'transparent' || value === 'inherit' || value === 'initial' || value === 'unset')
+ return false;
+ const color = cssColorToHex(value);
+ return color ? { color } : false;
+ },
+ },
+ ];
},
renderDOM({ htmlAttributes }) {
diff --git a/tests/visual/tests/behavior/formatting/paste-color-and-highlight.spec.ts b/tests/visual/tests/behavior/formatting/paste-color-and-highlight.spec.ts
new file mode 100644
index 0000000000..96ae0c83bf
--- /dev/null
+++ b/tests/visual/tests/behavior/formatting/paste-color-and-highlight.spec.ts
@@ -0,0 +1,51 @@
+import { test } from '../../fixtures/superdoc.js';
+
+test('@behavior pasting html with rgb() background-color applies highlight', async ({ superdoc }) => {
+ await superdoc.page.evaluate(() => {
+ (window as any).editor.commands.insertContent(
+ 'Yellow highlighted text',
+ );
+ });
+ await superdoc.screenshot('paste-rgb-background-color-highlight');
+});
+
+test('@behavior pasting html with transparent background-color applies no highlight', async ({ superdoc }) => {
+ await superdoc.page.evaluate(() => {
+ (window as any).editor.commands.insertContent(
+ 'No highlight text',
+ );
+ });
+ await superdoc.screenshot('paste-transparent-background-no-highlight');
+});
+
+test('@behavior pasting html with hex background-color applies highlight', async ({ superdoc }) => {
+ await superdoc.page.evaluate(() => {
+ (window as any).editor.commands.insertContent(
+ 'Yellow highlighted text',
+ );
+ });
+ await superdoc.screenshot('paste-hex-background-color-highlight');
+});
+
+test('@behavior pasting html with rgba zero-alpha background applies no highlight', async ({ superdoc }) => {
+ await superdoc.page.evaluate(() => {
+ (window as any).editor.commands.insertContent(
+ 'No highlight text',
+ );
+ });
+ await superdoc.screenshot('paste-rgba-zero-alpha-no-highlight');
+});
+
+test('@behavior pasting html with rgb() text color is applied', async ({ superdoc }) => {
+ await superdoc.page.evaluate(() => {
+ (window as any).editor.commands.insertContent('Red text');
+ });
+ await superdoc.screenshot('paste-rgb-text-color');
+});
+
+test('@behavior pasting html with hex text color is applied', async ({ superdoc }) => {
+ await superdoc.page.evaluate(() => {
+ (window as any).editor.commands.insertContent('Red text');
+ });
+ await superdoc.screenshot('paste-hex-text-color');
+});