From 3bcafbe7bc0c20e59a344051da9563dd7ef664d3 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sat, 14 Feb 2026 18:15:17 -0300 Subject: [PATCH 1/7] fix: normalize rgb color to hex to be properly applied on paste --- .../src/core/utilities/cssColorToHex.js | 28 ++++++++++++ .../super-editor/src/core/utilities/index.js | 1 + .../core/utilities/tests/utilities.test.js | 45 +++++++++++++++++++ .../src/extensions/color/color.js | 3 +- .../src/extensions/highlight/highlight.js | 3 +- 5 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 packages/super-editor/src/core/utilities/cssColorToHex.js 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..503b660513 --- /dev/null +++ b/packages/super-editor/src/core/utilities/cssColorToHex.js @@ -0,0 +1,28 @@ +/** + * Converts a CSS color value to hex format (#RRGGBB). + * Handles rgb(), rgba(), hex, and returns null for empty/invalid input. + * + * @param {string|null|undefined} cssColor - A CSS color string + * @returns {string|null} Hex 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+)/); + if (rgbMatch) { + const [, r, g, b] = rgbMatch; + 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..8cce207b5f 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,48 @@ 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('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..e337ed6511 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?.replace(/['"]+/g, '')), 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..294bf54d66 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) => element.getAttribute('data-color') || cssColorToHex(element.style.backgroundColor), renderDOM: (attributes) => { if (!attributes.color) { return {}; From 459e0b0ab2d90e32720e680e336d659a573e6a99 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sat, 14 Feb 2026 18:40:27 -0300 Subject: [PATCH 2/7] fix: parse background-color when pasting from word/docs --- .../src/extensions/highlight/highlight.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/extensions/highlight/highlight.js b/packages/super-editor/src/extensions/highlight/highlight.js index 294bf54d66..048d131cba 100644 --- a/packages/super-editor/src/extensions/highlight/highlight.js +++ b/packages/super-editor/src/extensions/highlight/highlight.js @@ -50,7 +50,16 @@ export const Highlight = Mark.create({ }, parseDOM() { - return [{ tag: 'mark' }]; + return [ + { tag: 'mark' }, + { + style: 'background-color', + getAttrs: (value) => { + if (!value || value === 'transparent' || value === 'inherit') return false; + return { color: cssColorToHex(value) }; + }, + }, + ]; }, renderDOM({ htmlAttributes }) { From dd3ee0f29de05b504b0fbf5ca26a017ce4f38782 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 15 Feb 2026 16:24:33 -0300 Subject: [PATCH 3/7] chore: improve edge cases handling for cssColorToHex util --- .../src/core/utilities/cssColorToHex.js | 11 ++++++++--- .../src/core/utilities/tests/utilities.test.js | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/core/utilities/cssColorToHex.js b/packages/super-editor/src/core/utilities/cssColorToHex.js index 503b660513..a0b6c4b375 100644 --- a/packages/super-editor/src/core/utilities/cssColorToHex.js +++ b/packages/super-editor/src/core/utilities/cssColorToHex.js @@ -1,6 +1,7 @@ /** * Converts a CSS color value to hex format (#RRGGBB). - * Handles rgb(), rgba(), hex, and returns null for empty/invalid input. + * 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} Hex color string or null @@ -16,9 +17,13 @@ export function cssColorToHex(cssColor) { } // Parse rgb(r, g, b) or rgba(r, g, b, a) - const rgbMatch = trimmed.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); + const rgbMatch = trimmed.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/); if (rgbMatch) { - const [, r, g, b] = 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(''); } 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 8cce207b5f..8cad9bdc07 100644 --- a/packages/super-editor/src/core/utilities/tests/utilities.test.js +++ b/packages/super-editor/src/core/utilities/tests/utilities.test.js @@ -253,6 +253,23 @@ describe('core utilities', () => { 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'); }); From 36d4a5bc4ec781caef3b7ecbc3737d3da0f7da55 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Wed, 18 Feb 2026 17:24:23 -0300 Subject: [PATCH 4/7] refactor: update how color and highlight is being normalized --- packages/super-editor/src/extensions/color/color.js | 2 +- .../super-editor/src/extensions/highlight/highlight.js | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/extensions/color/color.js b/packages/super-editor/src/extensions/color/color.js index e337ed6511..9b1f4ee427 100644 --- a/packages/super-editor/src/extensions/color/color.js +++ b/packages/super-editor/src/extensions/color/color.js @@ -49,7 +49,7 @@ export const Color = Extension.create({ attributes: { color: { default: null, - parseDOM: (el) => cssColorToHex(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 048d131cba..13e01061fa 100644 --- a/packages/super-editor/src/extensions/highlight/highlight.js +++ b/packages/super-editor/src/extensions/highlight/highlight.js @@ -35,7 +35,7 @@ export const Highlight = Mark.create({ return { color: { default: null, - parseDOM: (element) => element.getAttribute('data-color') || cssColorToHex(element.style.backgroundColor), + parseDOM: (element) => cssColorToHex(element.getAttribute('data-color') || element.style.backgroundColor), renderDOM: (attributes) => { if (!attributes.color) { return {}; @@ -55,8 +55,9 @@ export const Highlight = Mark.create({ { style: 'background-color', getAttrs: (value) => { - if (!value || value === 'transparent' || value === 'inherit') return false; - return { color: cssColorToHex(value) }; + if (!value) return false; + const color = cssColorToHex(value); + return color ? { color } : false; }, }, ]; From 8611418bc4e6bf883570996c1083a04c823436a8 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Wed, 18 Feb 2026 17:25:07 -0300 Subject: [PATCH 5/7] docs: update cssColorToHex jsdoc to better align the returned value --- packages/super-editor/src/core/utilities/cssColorToHex.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/super-editor/src/core/utilities/cssColorToHex.js b/packages/super-editor/src/core/utilities/cssColorToHex.js index a0b6c4b375..ec75249dc1 100644 --- a/packages/super-editor/src/core/utilities/cssColorToHex.js +++ b/packages/super-editor/src/core/utilities/cssColorToHex.js @@ -4,7 +4,7 @@ * Named colors are returned as-is. * * @param {string|null|undefined} cssColor - A CSS color string - * @returns {string|null} Hex color string or null + * @returns {string|null} Normalized color string or null */ export function cssColorToHex(cssColor) { if (!cssColor) return null; From 7a026596d1f282e66fa538954142a303e6d5313b Mon Sep 17 00:00:00 2001 From: Gabriel Date: Wed, 18 Feb 2026 17:31:21 -0300 Subject: [PATCH 6/7] feat: explictly check for transparent/inherit/initial/unset values on highlight parse --- packages/super-editor/src/extensions/highlight/highlight.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/extensions/highlight/highlight.js b/packages/super-editor/src/extensions/highlight/highlight.js index 13e01061fa..083a3b5e22 100644 --- a/packages/super-editor/src/extensions/highlight/highlight.js +++ b/packages/super-editor/src/extensions/highlight/highlight.js @@ -55,7 +55,8 @@ export const Highlight = Mark.create({ { style: 'background-color', getAttrs: (value) => { - if (!value) return false; + if (!value || value === 'transparent' || value === 'inherit' || value === 'initial' || value === 'unset') + return false; const color = cssColorToHex(value); return color ? { color } : false; }, From 3cf88605e8e1caccb68ea16de5049be2ca0f47b1 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 20 Feb 2026 16:21:58 -0300 Subject: [PATCH 7/7] test: add behaviour tests for color and highlight pasting --- .../paste-color-and-highlight.spec.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/visual/tests/behavior/formatting/paste-color-and-highlight.spec.ts 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'); +});