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 () => { test.each([
const markdown = `<span style="color: #00afde;">Hello</span>`; ['#00afde', 'blue'],
const blockSnapshot: BlockSnapshot = { ['rgb(0 175 222 / 100%)', 'blue'],
type: 'block', ['#c83030', 'red'],
id: 'matchesReplaceMap[0]', ['red', 'red'],
flavour: 'affine:note', ['hsl(0, 100%, 50%)', 'red'],
props: { ['#db7123', 'orange'],
xywh: '[0,0,800,95]', ['#ac7400', 'yellow'],
background: DefaultTheme.noteBackgrounColor, ['#04b745', 'green'],
index: 'a0', ['#0e4841', 'teal'],
hidden: false, ['#7c3aed', 'purple'],
displayMode: NoteDisplayMode.DocAndEdgeless, ['#7a7a7a', 'grey'],
}, ['rgb(26, 26, 26)', null],
children: [ ['#333', null],
{ ['#fff', null],
type: 'block', ['rgba(0, 175, 222, 0.5)', null],
id: 'matchesReplaceMap[1]', ])('maps supported HTML color %s conservatively', async (color, mapped) => {
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'Hello',
attributes: {
color: 'var(--affine-v2-text-highlight-fg-blue)',
},
},
],
},
},
children: [],
},
],
};
const mdAdapter = new MarkdownAdapter(createJob(), provider); const mdAdapter = new MarkdownAdapter(createJob(), provider);
const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ 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 () => { test('paragraph', async () => {
@@ -23,6 +23,7 @@
"@blocksuite/affine-shared": "workspace:*", "@blocksuite/affine-shared": "workspace:*",
"@blocksuite/std": "workspace:*", "@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*", "@blocksuite/store": "workspace:*",
"@ctrl/tinycolor": "^4.1.0",
"@toeverything/theme": "^1.1.23", "@toeverything/theme": "^1.1.23",
"@types/hast": "^3.0.4", "@types/hast": "^3.0.4",
"collapse-white-space": "^2.1.0", "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'; import { cssVarV2, darkThemeV2, lightThemeV2 } from '@toeverything/theme/v2';
type Rgb = { r: number; g: number; b: number }; 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 = [ const supportedTextColorNames = [
'red', 'red',
'orange', 'orange',
@@ -15,68 +22,81 @@ const supportedTextColorNames = [
'grey', 'grey',
] as const; ] as const;
const supportedTextColors = supportedTextColorNames.map(name => ({ export const parseCssColor = (value: string) => {
name, const parsed = new TinyColor(value);
cssVar: cssVarV2(`text/highlight/fg/${name}`), if (!parsed.isValid) return null;
light: lightThemeV2[`text/highlight/fg/${name}`], const { r, g, b, a } = parsed.toRgb();
dark: darkThemeV2[`text/highlight/fg/${name}`], return { r, g, b, alpha: a };
})); };
const hexToRgb = (value: string): Rgb | null => { const srgbToLinear = (channel: number) => {
const hex = value.replace('#', ''); const value = channel / 255;
if (![3, 4, 6, 8].includes(hex.length)) { return value <= 0.04045
return null; ? value / 12.92
} : Math.pow((value + 0.055) / 1.055, 2.4);
const normalized = };
hex.length === 3 || hex.length === 4
? hex const rgbToOklab = ({ r, g, b }: Rgb): Oklab => {
.slice(0, 3) const red = srgbToLinear(r);
.split('') const green = srgbToLinear(g);
.map(c => c + c) const blue = srgbToLinear(b);
.join('') const l = Math.cbrt(
: hex.slice(0, 6); 0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue
const intVal = Number.parseInt(normalized, 16); );
if (Number.isNaN(intVal)) { const m = Math.cbrt(
return null; 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 { return {
r: (intVal >> 16) & 255, ...result,
g: (intVal >> 8) & 255, chroma: Math.hypot(result.a, result.b),
b: intVal & 255, hue: (Math.atan2(result.b, result.a) * 180) / Math.PI,
}; };
}; };
export const parseCssColor = (value: string): Rgb | null => { const supportedTextColors = supportedTextColorNames.map(name => ({
const trimmed = value.trim(); name,
if (!trimmed) { cssVar: cssVarV2(`text/highlight/fg/${name}`),
return null; references: [
} lightThemeV2[`text/highlight/fg/${name}`],
if (trimmed.startsWith('#')) { darkThemeV2[`text/highlight/fg/${name}`],
return hexToRgb(trimmed); ].flatMap(color => {
} const parsed = parseCssColor(color);
if (/^rgba?\(/i.test(trimmed)) { return parsed ? [rgbToOklab(parsed)] : [];
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 colorDistance = (a: Rgb, b: Rgb) => { const colorDistance = (a: Oklab, b: Oklab) =>
const dr = a.r - b.r; Math.hypot(a.l - b.l, a.a - b.a, a.b - b.b);
const dg = a.g - b.g;
const db = a.b - b.b; const hueDistance = (a: number, b: number) => {
return Math.sqrt(dr * dr + dg * dg + db * db); const distance = Math.abs(a - b) % 360;
return Math.min(distance, 360 - distance);
}; };
export const resolveNearestSupportedColor = (color: string): string | null => { export const resolveNearestSupportedColor = (color: string): string | null => {
const target = parseCssColor(color); const parsed = parseCssColor(color);
if (!target) { if (!parsed || parsed.alpha < 1) {
return null; 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: let nearest:
| { | {
cssVar: string; cssVar: string;
@@ -85,21 +105,26 @@ export const resolveNearestSupportedColor = (color: string): string | null => {
| undefined; | undefined;
for (const supported of supportedTextColors) { for (const supported of supportedTextColors) {
const light = parseCssColor(supported.light); if (achromatic !== (supported.name === 'grey')) {
const dark = parseCssColor(supported.dark); continue;
for (const ref of [light, dark]) { }
if (!ref) continue; for (const reference of supported.references) {
const distance = colorDistance(target, ref); 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) { if (!nearest || distance < nearest.distance) {
nearest = { cssVar: supported.cssVar, distance }; nearest = { cssVar: supported.cssVar, distance };
} }
} }
} }
if (nearest && nearest.distance <= COLOR_DISTANCE_THRESHOLD) { return nearest?.cssVar ?? null;
return nearest.cssVar;
}
return null;
}; };
export const extractColorFromStyle = ( export const extractColorFromStyle = (
@@ -110,10 +135,11 @@ export const extractColorFromStyle = (
} }
const declarations = style.split(';'); const declarations = style.split(';');
for (const declaration of declarations) { for (const declaration of declarations) {
const [rawKey, rawValue] = declaration.split(':'); const colon = declaration.indexOf(':');
if (!rawKey || !rawValue) continue; if (colon === -1) continue;
if (rawKey.trim().toLowerCase() === 'color') { const key = declaration.slice(0, colon).trim().toLowerCase();
return rawValue.trim(); if (key === 'color') {
return declaration.slice(colon + 1).trim();
} }
} }
return null; return null;
+1
View File
@@ -2795,6 +2795,7 @@ __metadata:
"@blocksuite/affine-shared": "workspace:*" "@blocksuite/affine-shared": "workspace:*"
"@blocksuite/std": "workspace:*" "@blocksuite/std": "workspace:*"
"@blocksuite/store": "workspace:*" "@blocksuite/store": "workspace:*"
"@ctrl/tinycolor": "npm:^4.1.0"
"@toeverything/theme": "npm:^1.1.23" "@toeverything/theme": "npm:^1.1.23"
"@types/hast": "npm:^3.0.4" "@types/hast": "npm:^3.0.4"
collapse-white-space: "npm:^2.1.0" collapse-white-space: "npm:^2.1.0"