feat(editor): support frontmatter & colored text parsing (#14205)

fix #13847
This commit is contained in:
DarkSky
2026-01-03 22:43:11 +08:00
committed by GitHub
parent 510933becf
commit fe5d6c0c0f
16 changed files with 1033 additions and 423 deletions

View File

@@ -0,0 +1,120 @@
import { parseStringToRgba } from '@blocksuite/affine-components/color-picker';
import { cssVarV2, darkThemeV2, lightThemeV2 } from '@toeverything/theme/v2';
type Rgb = { r: number; g: number; b: number };
const COLOR_DISTANCE_THRESHOLD = 90;
const supportedTextColorNames = [
'red',
'orange',
'yellow',
'green',
'teal',
'blue',
'purple',
'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}`],
}));
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;
}
return {
r: (intVal >> 16) & 255,
g: (intVal >> 8) & 255,
b: intVal & 255,
};
};
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 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);
};
export const resolveNearestSupportedColor = (color: string): string | null => {
const target = parseCssColor(color);
if (!target) {
return null;
}
let nearest:
| {
cssVar: string;
distance: number;
}
| 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 (!nearest || distance < nearest.distance) {
nearest = { cssVar: supported.cssVar, distance };
}
}
}
if (nearest && nearest.distance <= COLOR_DISTANCE_THRESHOLD) {
return nearest.cssVar;
}
return null;
};
export const extractColorFromStyle = (
style: string | undefined
): string | null => {
if (typeof style !== 'string') {
return null;
}
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();
}
}
return null;
};

View File

@@ -5,6 +5,11 @@ import {
import { collapseWhiteSpace } from 'collapse-white-space';
import type { Element } from 'hast';
import {
extractColorFromStyle,
resolveNearestSupportedColor,
} from './color-utils.js';
/**
* Handle empty text nodes created by HTML parser for styling purposes.
* These nodes typically contain only whitespace/newlines, for example:
@@ -173,6 +178,40 @@ export const htmlTextToDeltaMatcher = HtmlASTToDeltaExtension({
},
});
export const htmlColorStyleElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'color-style-element',
match: ast =>
isElement(ast) &&
ast.tagName === 'span' &&
typeof ast.properties?.style === 'string' &&
/color\s*:/i.test(ast.properties.style),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const baseOptions = { ...context.options, trim: false };
// In preformatted contexts (e.g. code blocks) we don't keep inline colors.
if (baseOptions.pre) {
return ast.children.flatMap(child => context.toDelta(child, baseOptions));
}
const colorValue = extractColorFromStyle(
typeof ast.properties?.style === 'string' ? ast.properties.style : ''
);
const mappedColor = colorValue
? resolveNearestSupportedColor(colorValue)
: null;
const deltas = ast.children.flatMap(child =>
context.toDelta(child, baseOptions).map(delta => {
if (mappedColor) {
delta.attributes = { ...delta.attributes, color: mappedColor };
}
return delta;
})
);
return deltas;
},
});
export const htmlTextLikeElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'text-like-element',
match: ast => isTextLikeElement(ast),
@@ -300,6 +339,7 @@ export const htmlBrElementToDeltaMatcher = HtmlASTToDeltaExtension({
export const HtmlInlineToDeltaAdapterExtensions = [
htmlTextToDeltaMatcher,
htmlColorStyleElementToDeltaMatcher,
htmlTextLikeElementToDeltaMatcher,
htmlStrongElementToDeltaMatcher,
htmlItalicElementToDeltaMatcher,

View File

@@ -79,11 +79,11 @@ export const markdownListToDeltaMatcher = MarkdownASTToDeltaExtension({
export const markdownHtmlToDeltaMatcher = MarkdownASTToDeltaExtension({
name: 'html',
match: ast => ast.type === 'html',
toDelta: ast => {
toDelta: (ast, context) => {
if (!('value' in ast)) {
return [];
}
return [{ insert: ast.value }];
return context?.htmlToDelta?.(ast.value) ?? [{ insert: ast.value }];
},
});