mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-01 22:29:44 +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 () => {
|
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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user