fix: correctly set color and highlight of pasted text#2033
fix: correctly set color and highlight of pasted text#2033gm1357 wants to merge 6 commits intosuperdoc-dev:mainfrom
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 459e0b0ab2
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (!value || value === 'transparent' || value === 'inherit') return false; | ||
| return { color: cssColorToHex(value) }; |
There was a problem hiding this comment.
Skip transparent rgba background colors in highlight parse
The new background-color parse rule treats any non-transparent/inherit value as a highlight, but many pasted HTML sources encode “no background” as rgba(0, 0, 0, 0). In that case cssColorToHex drops alpha and returns #000000, so plain text is imported with an unintended black highlight mark. This regression is introduced by the new style-based parse path and will surface on paste inputs that use transparent RGBA values.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
Fixes pasted text color/highlight rendering by normalizing pasted CSS rgb()/rgba() color values into hex at parse-time, so downstream rendering logic receives consistent color formats.
Changes:
- Added
cssColorToHexutility and exported it from core utilities. - Applied
cssColorToHexinColorandHighlightextensions’ DOM parsing (including a newbackground-colorstyle parse rule for highlights). - Added unit tests for
cssColorToHex.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/super-editor/src/extensions/highlight/highlight.js | Normalize pasted highlight colors and capture <span style="background-color: ..."> highlights. |
| packages/super-editor/src/extensions/color/color.js | Normalize pasted text color values to hex during DOM parsing. |
| packages/super-editor/src/core/utilities/cssColorToHex.js | New utility to convert CSS rgb/rgba strings to hex (plus passthrough behavior). |
| packages/super-editor/src/core/utilities/index.js | Re-export cssColorToHex from the utilities barrel. |
| packages/super-editor/src/core/utilities/tests/utilities.test.js | Added test coverage for cssColorToHex. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // 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(''); | ||
| } |
There was a problem hiding this comment.
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.
| color: { | ||
| default: null, | ||
| parseDOM: (element) => element.getAttribute('data-color') || element.style.backgroundColor, | ||
| parseDOM: (element) => element.getAttribute('data-color') || cssColorToHex(element.style.backgroundColor), |
There was a problem hiding this comment.
parseDOM prefers data-color as-is, so any existing content (or copy/paste within SuperDoc) that has data-color="rgb(...)"/"rgba(...)" will still bypass cssColorToHex and reintroduce the original rendering issue. Consider applying cssColorToHex to the data-color value too (and falling back to backgroundColor if it returns null).
| parseDOM: (element) => element.getAttribute('data-color') || cssColorToHex(element.style.backgroundColor), | |
| parseDOM: (element) => { | |
| const dataColor = element.getAttribute('data-color'); | |
| const parsedDataColor = cssColorToHex(dataColor); | |
| if (parsedDataColor) { | |
| return parsedDataColor; | |
| } | |
| return cssColorToHex(element.style.backgroundColor); | |
| }, |
| { | ||
| style: 'background-color', | ||
| getAttrs: (value) => { | ||
| if (!value || value === 'transparent' || value === 'inherit') return false; |
There was a problem hiding this comment.
The transparent / inherit checks are strict string comparisons on the raw style value. Since style values can vary in casing or include whitespace (and you already trim in cssColorToHex), it’d be safer to normalize value (trim + lowercase) before these checks to avoid accidentally creating a highlight mark for " transparent ", "Transparent", etc.
| if (!value || value === 'transparent' || value === 'inherit') return false; | |
| if (!value) return false; | |
| const normalized = value.trim().toLowerCase(); | |
| if (normalized === 'transparent' || normalized === 'inherit') return false; |
| style: 'background-color', | ||
| getAttrs: (value) => { | ||
| if (!value || value === 'transparent' || value === 'inherit') return false; | ||
| return { color: cssColorToHex(value) }; |
There was a problem hiding this comment.
getAttrs always returns a highlight mark when value is truthy, even if cssColorToHex(value) returns null (e.g. whitespace-only). That would create a highlight mark with color: null, which may lead to unexpected default <mark> styling. Consider computing the converted color first and returning false when the conversion yields null.
| return { color: cssColorToHex(value) }; | |
| const convertedColor = cssColorToHex(value); | |
| if (!convertedColor) return false; | |
| return { color: convertedColor }; |
| * 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 |
There was a problem hiding this comment.
JSDoc says this converts to "#RRGGBB" and returns null for empty/invalid input, but the implementation (and tests) currently pass through 3-digit hex and return the original string for any unrecognized value (including invalid colors). Please either tighten validation + normalize to 6-digit hex, or update the docstring to match the actual behavior/contract.
| * 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 | |
| * Converts a CSS color value to a hex color when possible. | |
| * | |
| * - If `cssColor` is null/undefined/empty (or whitespace only), returns null. | |
| * - If `cssColor` is a 3- or 6-digit hex color (e.g. `#fff`, `#ffffff`), it is returned as-is. | |
| * - If `cssColor` is in rgb() / rgba() form, it is converted to a 6-digit hex string (#RRGGBB). | |
| * - For any other value (named colors, other formats, or unrecognized strings), the trimmed input is returned unchanged. | |
| * | |
| * @param {string|null|undefined} cssColor - A CSS color string | |
| * @returns {string|null} A 6-digit hex color string (#RRGGBB) when converted, the original color string for non-rgb(a)/unrecognized input, or null for empty input |
caio-pizzol
left a comment
There was a problem hiding this comment.
Hey @gm1357, great start!
The root cause analysis is spot on and converting at parse time is the right approach. cssColorToHex is clean and well-tested.
Couple of things to address before merging (details in inline comments)
Also, it would be great to add a behavior test in tests/visual/tests/behavior/formatting/ that inserts HTML with rgb() colors and screenshots the result
| style: 'background-color', | ||
| getAttrs: (value) => { | ||
| if (!value || value === 'transparent' || value === 'inherit') return false; | ||
| return { color: cssColorToHex(value) }; |
There was a problem hiding this comment.
cssColorToHex(value) can return null here -- for example when the pasted HTML has rgba(0, 0, 0, 0) (a common way browsers encode "no background"). That value passes the transparent/inherit guard on line 58, but cssColorToHex correctly returns null for zero-alpha.
The problem is this still returns { color: null }, which tells ProseMirror to create a highlight mark with no color -- a phantom mark in the document.
getAttrs: (value) => {
if (!value || value === 'transparent' || value === 'inherit') return false;
const color = cssColorToHex(value);
return color ? { color } : false;
},
| color: { | ||
| default: null, | ||
| parseDOM: (element) => element.getAttribute('data-color') || element.style.backgroundColor, | ||
| parseDOM: (element) => element.getAttribute('data-color') || cssColorToHex(element.style.backgroundColor), |
There was a problem hiding this comment.
data-color is used as-is without going through cssColorToHex. This matters because SuperDoc sets data-color to whatever the stored color attribute is (line 44). If a document was edited before this fix, the stored value could be rgb(255, 0, 0). When that content is copy-pasted within SuperDoc, the pasted <mark> has data-color="rgb(255, 0, 0)" which gets stored raw.
Downstream, normalizeColor in pm-adapter/src/utilities.ts prepends # to anything that doesn't start with it, producing "#rgb(255, 0, 0)" which breaks rendering in presentation mode.
parseDOM: (element) => cssColorToHex(element.getAttribute('data-color')) || cssColorToHex(element.style.backgroundColor),
| { tag: 'mark' }, | ||
| { | ||
| style: 'background-color', | ||
| getAttrs: (value) => { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| color: { | ||
| default: null, | ||
| parseDOM: (el) => el.style.color?.replace(/['"]+/g, ''), | ||
| parseDOM: (el) => cssColorToHex(el.style.color?.replace(/['"]+/g, '')), |
There was a problem hiding this comment.
The .replace(/['"]+/g, '') is left over from the previous code -- el.style.color is a browser-computed property that never contains quotes.
Safe to drop it now that you're already editing this line:
parseDOM: (el) => cssColorToHex(el.style.color),
| * Named colors are returned as-is. | ||
| * | ||
| * @param {string|null|undefined} cssColor - A CSS color string | ||
| * @returns {string|null} Hex color string or null |
There was a problem hiding this comment.
@returns {string|null} Hex color string or null -- this can also return named colors ("red") and 3-digit hex ("#f00") unchanged, so "Hex color string" isn't quite right.
Something like Normalized color string or null would match the actual contract better.
|
Thanks for the reviews @tupizz and @caio-pizzol! @caio-pizzol I adressed your in-line comments. Regarding the behaviour tests you suggested, I will take a look at it later and add it. |
Problem
When pasting text with color or highlight from Word, Google Docs, or within SuperDoc, the toolbar shows the correct color but the text renders without it.
Cause
Pasted colors were coming as rgb() format, but the rendering pipeline's
normalizeColoronly handles hex, it prepends # to non-hex values, producing invalid strings like #rgb(255, 0, 0).Fix
cssColorToHexutility that converts rgb()/rgba() values to hex format{ style: 'background-color' }parse rule so highlights pasted as<span style="background-color: ...">(Word/Google Docs format) are correctly captured, previously only<mark>tags were matchedComparisons
Before
Screen.Recording.2026-02-15.152442.mp4
Screen.Recording.2026-02-15.152703.mp4
After
Screen.Recording.2026-02-15.153003.mp4
Screen.Recording.2026-02-15.152828.mp4