From 8e8a781ed14f4e52cc5e68c036efffc4f79053e1 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:52:02 +0800 Subject: [PATCH] feat(editor): improve html content color mapping (#15516) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix #15514 #### PR Dependency Tree * **PR #15516** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) ## 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. --- .../__tests__/adapters/markdown.unit.spec.ts | 70 ++++---- blocksuite/affine/inlines/preset/package.json | 1 + .../preset/src/adapters/html/color-utils.ts | 158 ++++++++++-------- yarn.lock | 1 + 4 files changed, 125 insertions(+), 105 deletions(-) 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"