mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-30 21:29:37 +08:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user