Skip to content
Merged
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 thread
caio-pizzol marked this conversation as resolved.

// 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';
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) => {
Comment thread
caio-pizzol marked this conversation as resolved.
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');
});
Loading