feat(editor): support frontmatter & colored text parsing (#14205)
fix #13847
This commit is contained in:
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }];
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user