Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions packages/super-editor/src/core/utilities/cssColorToHex.js
Original file line number Diff line number Diff line change
@@ -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('');
}
Comment on lines 19 to 28
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rgb/rgba parsing is case-sensitive and not anchored to the closing ")", so values like "RGB(255,0,0)" or strings with extra trailing characters won't be converted. Also, channels aren't clamped to 0–255; if a value like "rgb(300,0,0)" is encountered, the generated hex will be longer than 6 digits and invalid. Consider using a case-insensitive, fully-anchored regex and clamping channels to [0,255] before hex conversion.

Copilot uses AI. Check for mistakes.

// Return as-is for other valid formats (named colors, etc.)
// Browsers normalize pasted colors to rgb(), so this is a rare fallback
return trimmed;
}
1 change: 1 addition & 0 deletions packages/super-editor/src/core/utilities/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export * from './deleteProps.js';
export * from './parseSizeUnit.js';
export * from './minMax.js';
export * from './clipboardUtils.js';
export * from './cssColorToHex.js';
62 changes: 62 additions & 0 deletions packages/super-editor/src/core/utilities/tests/utilities.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
});
});
});
3 changes: 2 additions & 1 deletion packages/super-editor/src/extensions/color/color.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// @ts-nocheck
import { Extension } from '@core/index.js';
import { cssColorToHex } from '@core/utilities/cssColorToHex.js';

/**
* Color value format
Expand Down Expand Up @@ -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}` };
Expand Down
16 changes: 14 additions & 2 deletions packages/super-editor/src/extensions/highlight/highlight.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// @ts-nocheck
import { Mark, Attribute } from '@core/index.js';
import { cssColorToHex } from '@core/utilities/cssColorToHex.js';

/**
* Configuration options for Highlight
Expand Down Expand Up @@ -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 {};
Expand All @@ -49,7 +50,18 @@ export const Highlight = Mark.create({
},

parseDOM() {
return [{ tag: 'mark' }];
return [
{ tag: 'mark' },
{
style: 'background-color',
getAttrs: (value) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the transparent/inherit check here and the null handling inside cssColorToHex are doing related work in two places. If you add the null guard from comment 1, you could simplify this to just:

  getAttrs: (value) => {
    if (!value) return false;
    const color = cssColorToHex(value);
    return color ? { color } : false;
  },

Since cssColorToHex('transparent') would return "transparent" (named color passthrough), you'd want to either keep the explicit check here, or teach cssColorToHex to return null for CSS keywords like transparent/inherit/initial.

Either way works -- just pick one place for that logic.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adressed on 7a02659.

Chose to keep the logic explictly here so that cssColorToHex can be a bit more agnostic on how to handle those values.

if (!value || value === 'transparent' || value === 'inherit' || value === 'initial' || value === 'unset')
return false;
const color = cssColorToHex(value);
return color ? { color } : false;
},
},
];
},

renderDOM({ htmlAttributes }) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
'<span style="background-color: rgb(255, 255, 0)">Yellow highlighted text</span>',
);
});
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(
'<span style="background-color: transparent">No highlight text</span>',
);
});
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(
'<span style="background-color: #ffff00">Yellow highlighted text</span>',
);
});
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(
'<span style="background-color: rgba(255, 0, 0, 0)">No highlight text</span>',
);
});
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('<span style="color: rgb(255, 0, 0)">Red text</span>');
});
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('<span style="color: #ff0000">Red text</span>');
});
await superdoc.screenshot('paste-hex-text-color');
});