diff --git a/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts b/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts
index 8a3cded89c..7883a2dd62 100644
--- a/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts
+++ b/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts
@@ -3929,48 +3929,40 @@ describe('markdown to snapshot', () => {
});
});
- test('html inline color span imports to nearest supported text color', async () => {
- const markdown = `Hello`;
- 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: `Hello`,
+ });
+ 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 () => {
diff --git a/blocksuite/affine/inlines/preset/package.json b/blocksuite/affine/inlines/preset/package.json
index b47c650d94..36fdbe0ca8 100644
--- a/blocksuite/affine/inlines/preset/package.json
+++ b/blocksuite/affine/inlines/preset/package.json
@@ -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",
diff --git a/blocksuite/affine/inlines/preset/src/adapters/html/color-utils.ts b/blocksuite/affine/inlines/preset/src/adapters/html/color-utils.ts
index 724be52354..0c08f02882 100644
--- a/blocksuite/affine/inlines/preset/src/adapters/html/color-utils.ts
+++ b/blocksuite/affine/inlines/preset/src/adapters/html/color-utils.ts
@@ -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;
diff --git a/yarn.lock b/yarn.lock
index fbf69c42fb..187f97dba3 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"