feat(editor): improve html content color mapping (#15516)

fix #15514

#### PR Dependency Tree


* **PR #15516** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
  * Improved color handling when importing HTML content.
* Added support for hex, RGB/RGBA, percentage, alpha, transparent,
named, and HSL color values.
  * Correctly maps supported colors to the app’s color themes.
* Prevents invalid, translucent, or unsuitable colors from being
applied.
  * Preserves style values containing additional colons during import.
* Improved imported text formatting for supported and unsupported
colors.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-08-24 10:52:02 +08:00
committed by GitHub
parent cd6593c659
commit 8e8a781ed1
4 changed files with 125 additions and 105 deletions
@@ -3929,48 +3929,40 @@ describe('markdown to snapshot', () => {
});
});
test('html inline color span imports to nearest supported text color', async () => {
const markdown = `<span style="color: #00afde;">Hello</span>`;
const blockSnapshot: BlockSnapshot = {
type: 'block',
id: 'matchesReplaceMap[0]',
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [
{
type: 'block',
id: 'matchesReplaceMap[1]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'Hello',
attributes: {
color: 'var(--affine-v2-text-highlight-fg-blue)',
},
},
],
},
},
children: [],
},
],
};
test.each([
['#00afde', 'blue'],
['rgb(0 175 222 / 100%)', 'blue'],
['#c83030', 'red'],
['red', 'red'],
['hsl(0, 100%, 50%)', 'red'],
['#db7123', 'orange'],
['#ac7400', 'yellow'],
['#04b745', 'green'],
['#0e4841', 'teal'],
['#7c3aed', 'purple'],
['#7a7a7a', 'grey'],
['rgb(26, 26, 26)', null],
['#333', null],
['#fff', null],
['rgba(0, 175, 222, 0.5)', null],
])('maps supported HTML color %s conservatively', async (color, mapped) => {
const mdAdapter = new MarkdownAdapter(createJob(), provider);
const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({
file: markdown,
file: `<span style="color: ${color};">Hello</span>`,
});
expect(rawBlockSnapshot.children[0]?.props.text).toEqual({
'$blocksuite:internal:text$': true,
delta: [
mapped
? {
insert: 'Hello',
attributes: {
color: `var(--affine-v2-text-highlight-fg-${mapped})`,
},
}
: { insert: 'Hello' },
],
});
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
});
test('paragraph', async () => {
@@ -23,6 +23,7 @@
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
"@ctrl/tinycolor": "^4.1.0",
"@toeverything/theme": "^1.1.23",
"@types/hast": "^3.0.4",
"collapse-white-space": "^2.1.0",
@@ -1,9 +1,16 @@
import { parseStringToRgba } from '@blocksuite/affine-components/color-picker';
import { TinyColor } from '@ctrl/tinycolor';
import { cssVarV2, darkThemeV2, lightThemeV2 } from '@toeverything/theme/v2';
type Rgb = { r: number; g: number; b: number };
type Oklab = { l: number; a: number; b: number; chroma: number; hue: number };
const ACHROMATIC_CHROMA_THRESHOLD = 0.02;
const DEFAULT_TEXT_LIGHTNESS_MIN = 0.4;
const DEFAULT_TEXT_LIGHTNESS_MAX = 0.9;
const MAX_COLOR_DISTANCE = 0.18;
const MAX_CHROMA_DISTANCE = 0.12;
const MAX_HUE_DISTANCE = 45;
const COLOR_DISTANCE_THRESHOLD = 90;
const supportedTextColorNames = [
'red',
'orange',
@@ -15,68 +22,81 @@ const supportedTextColorNames = [
'grey',
] as const;
const supportedTextColors = supportedTextColorNames.map(name => ({
name,
cssVar: cssVarV2(`text/highlight/fg/${name}`),
light: lightThemeV2[`text/highlight/fg/${name}`],
dark: darkThemeV2[`text/highlight/fg/${name}`],
}));
export const parseCssColor = (value: string) => {
const parsed = new TinyColor(value);
if (!parsed.isValid) return null;
const { r, g, b, a } = parsed.toRgb();
return { r, g, b, alpha: a };
};
const hexToRgb = (value: string): Rgb | null => {
const hex = value.replace('#', '');
if (![3, 4, 6, 8].includes(hex.length)) {
return null;
}
const normalized =
hex.length === 3 || hex.length === 4
? hex
.slice(0, 3)
.split('')
.map(c => c + c)
.join('')
: hex.slice(0, 6);
const intVal = Number.parseInt(normalized, 16);
if (Number.isNaN(intVal)) {
return null;
}
const srgbToLinear = (channel: number) => {
const value = channel / 255;
return value <= 0.04045
? value / 12.92
: Math.pow((value + 0.055) / 1.055, 2.4);
};
const rgbToOklab = ({ r, g, b }: Rgb): Oklab => {
const red = srgbToLinear(r);
const green = srgbToLinear(g);
const blue = srgbToLinear(b);
const l = Math.cbrt(
0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue
);
const m = Math.cbrt(
0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue
);
const s = Math.cbrt(
0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue
);
const result = {
l: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
};
return {
r: (intVal >> 16) & 255,
g: (intVal >> 8) & 255,
b: intVal & 255,
...result,
chroma: Math.hypot(result.a, result.b),
hue: (Math.atan2(result.b, result.a) * 180) / Math.PI,
};
};
export const parseCssColor = (value: string): Rgb | null => {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith('#')) {
return hexToRgb(trimmed);
}
if (/^rgba?\(/i.test(trimmed)) {
const rgba = parseStringToRgba(trimmed);
return {
r: Math.round(rgba.r * 255),
g: Math.round(rgba.g * 255),
b: Math.round(rgba.b * 255),
};
}
return null;
};
const supportedTextColors = supportedTextColorNames.map(name => ({
name,
cssVar: cssVarV2(`text/highlight/fg/${name}`),
references: [
lightThemeV2[`text/highlight/fg/${name}`],
darkThemeV2[`text/highlight/fg/${name}`],
].flatMap(color => {
const parsed = parseCssColor(color);
return parsed ? [rgbToOklab(parsed)] : [];
}),
}));
const colorDistance = (a: Rgb, b: Rgb) => {
const dr = a.r - b.r;
const dg = a.g - b.g;
const db = a.b - b.b;
return Math.sqrt(dr * dr + dg * dg + db * db);
const colorDistance = (a: Oklab, b: Oklab) =>
Math.hypot(a.l - b.l, a.a - b.a, a.b - b.b);
const hueDistance = (a: number, b: number) => {
const distance = Math.abs(a - b) % 360;
return Math.min(distance, 360 - distance);
};
export const resolveNearestSupportedColor = (color: string): string | null => {
const target = parseCssColor(color);
if (!target) {
const parsed = parseCssColor(color);
if (!parsed || parsed.alpha < 1) {
return null;
}
const target = rgbToOklab(parsed);
const achromatic = target.chroma < ACHROMATIC_CHROMA_THRESHOLD;
if (
achromatic &&
(target.l < DEFAULT_TEXT_LIGHTNESS_MIN ||
target.l > DEFAULT_TEXT_LIGHTNESS_MAX)
) {
return null;
}
let nearest:
| {
cssVar: string;
@@ -85,21 +105,26 @@ export const resolveNearestSupportedColor = (color: string): string | null => {
| undefined;
for (const supported of supportedTextColors) {
const light = parseCssColor(supported.light);
const dark = parseCssColor(supported.dark);
for (const ref of [light, dark]) {
if (!ref) continue;
const distance = colorDistance(target, ref);
if (achromatic !== (supported.name === 'grey')) {
continue;
}
for (const reference of supported.references) {
const distance = colorDistance(target, reference);
if (
distance > MAX_COLOR_DISTANCE ||
(!achromatic &&
(Math.abs(target.chroma - reference.chroma) > MAX_CHROMA_DISTANCE ||
hueDistance(target.hue, reference.hue) > MAX_HUE_DISTANCE))
) {
continue;
}
if (!nearest || distance < nearest.distance) {
nearest = { cssVar: supported.cssVar, distance };
}
}
}
if (nearest && nearest.distance <= COLOR_DISTANCE_THRESHOLD) {
return nearest.cssVar;
}
return null;
return nearest?.cssVar ?? null;
};
export const extractColorFromStyle = (
@@ -110,10 +135,11 @@ export const extractColorFromStyle = (
}
const declarations = style.split(';');
for (const declaration of declarations) {
const [rawKey, rawValue] = declaration.split(':');
if (!rawKey || !rawValue) continue;
if (rawKey.trim().toLowerCase() === 'color') {
return rawValue.trim();
const colon = declaration.indexOf(':');
if (colon === -1) continue;
const key = declaration.slice(0, colon).trim().toLowerCase();
if (key === 'color') {
return declaration.slice(colon + 1).trim();
}
}
return null;
+1
View File
@@ -2795,6 +2795,7 @@ __metadata:
"@blocksuite/affine-shared": "workspace:*"
"@blocksuite/std": "workspace:*"
"@blocksuite/store": "workspace:*"
"@ctrl/tinycolor": "npm:^4.1.0"
"@toeverything/theme": "npm:^1.1.23"
"@types/hast": "npm:^3.0.4"
collapse-white-space: "npm:^2.1.0"