fix(editor): handle html content copied from google docs (#12383)

Closes: [BS-3508](https://linear.app/affine-design/issue/BS-3508/google-docs复制内容到affine时自动加粗问题)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Improved detection of bold, italic, underline, and strike-through formatting in imported HTML, supporting both tags and inline CSS styles.
  - Enhanced handling of inline elements containing block-level children to ensure correct formatting and structure during HTML import.
  - Introduced a plugin that converts inline elements with block-level children into block elements, preserving original tag information.
- **Bug Fixes**
  - Resolved issues where block-level elements nested inside inline tags could cause incorrect formatting or structure.
- **Tests**
  - Added comprehensive test coverage for HTML formatting conversions and plugin behavior to ensure accuracy and reliability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
donteatfriedrice
2025-05-21 02:45:17 +00:00
parent ae0dbb9faf
commit 1a070367f3
6 changed files with 568 additions and 6 deletions

View File

@@ -32,6 +32,122 @@ const listElementTags = new Set(['ol', 'ul']);
const strongElementTags = new Set(['strong', 'b']);
const italicElementTags = new Set(['i', 'em']);
/**
* Check if the element is a strong element through style or tag
* If the element tag is <strong>, <b> or the style is `font-weight: bold;`, or the font-weight is 500 or above,
* we consider it as a strong element
* @param ast - The HTML AST node to check
* @returns `true` if the element is a strong element, `false` otherwise
* @example
* ```html
* <strong>Hello</strong>
* <b>Hello</b>
* <span style="font-weight: bold;">Hello</span>
* <span style="font-weight: 700;">Hello</span>
* ```
*/
const isStrongElement = (ast: HtmlAST) => {
if (!isElement(ast)) {
return false;
}
const style =
typeof ast.properties.style === 'string' ? ast.properties.style : '';
const isStrongTag = strongElementTags.has(ast.tagName);
// Should exclude the case like <b style="font-weight: normal;">
const isNotNormalFontWeight = !/font-weight:\s*normal/.test(style);
const isBoldFontWeight = /font-weight:\s*(([5-9]\d{2})|bold)/.test(style);
return (isStrongTag && isNotNormalFontWeight) || isBoldFontWeight;
};
/**
* Check if the element is an italic element through style or tag
* If the element tag is <i>, <em> or the style is `font-style: italic;`,
* we consider it as an italic element
* @param ast - The HTML AST node to check
* @returns `true` if the element is an italic element, `false` otherwise
* @example
* ```html
* <i>Hello</i>
* <em>Hello</em>
* <span style="font-style: italic;">Hello</span>
* ```
*/
const isItalicElement = (ast: HtmlAST) => {
if (!isElement(ast)) {
return false;
}
const style =
typeof ast.properties.style === 'string' ? ast.properties.style : '';
const isItalicTag = italicElementTags.has(ast.tagName);
const isItalicStyle = /font-style:\s*italic/.test(style);
return isItalicTag || isItalicStyle;
};
/**
* Check if the element is an underline element through style or tag
* If the element tag is <u> or the style is `text-decoration: underline;`,
* we consider it as an underline element
* @param ast - The HTML AST node to check
* @returns `true` if the element is an underline element, `false` otherwise
* @example
* ```html
* <u>Hello</u>
* <span style="text-decoration: underline;">Hello</span>
* ```
*/
const isUnderlineElement = (ast: HtmlAST) => {
if (!isElement(ast)) {
return false;
}
const style =
typeof ast.properties.style === 'string' ? ast.properties.style : '';
const isUnderlineTag = ast.tagName === 'u';
const isUnderlineStyle = /text-decoration:\s*underline/.test(style);
return isUnderlineTag || isUnderlineStyle;
};
/**
* Check if the element is a line-through element through style or tag
* If the element tag is <del> or the style is `text-decoration: line-through;`,
* we consider it as a line-through element
* @param ast - The HTML AST node to check
* @returns `true` if the element is a line-through element, `false` otherwise
* @example
* ```html
* <del>Hello</del>
* <span style="text-decoration: line-through;">Hello</span>
* ```
*/
const isLineThroughElement = (ast: HtmlAST) => {
if (!isElement(ast)) {
return false;
}
const style =
typeof ast.properties.style === 'string' ? ast.properties.style : '';
const isLineThroughTag = ast.tagName === 'del';
const isLineThroughStyle = /text-decoration:\s*line-through/.test(style);
return isLineThroughTag || isLineThroughStyle;
};
/**
* Handle the case like <span>Hello</span>
* @param ast
* @returns
*/
const isTextLikeElement = (ast: HtmlAST) => {
if (!isElement(ast)) {
return false;
}
return (
textLikeElementTags.has(ast.tagName) &&
!isStrongElement(ast) &&
!isItalicElement(ast) &&
!isUnderlineElement(ast) &&
!isLineThroughElement(ast)
);
};
export const htmlTextToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'text',
match: ast => ast.type === 'text',
@@ -59,7 +175,7 @@ export const htmlTextToDeltaMatcher = HtmlASTToDeltaExtension({
export const htmlTextLikeElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'text-like-element',
match: ast => isElement(ast) && textLikeElementTags.has(ast.tagName),
match: ast => isTextLikeElement(ast),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
@@ -80,7 +196,7 @@ export const htmlListToDeltaMatcher = HtmlASTToDeltaExtension({
export const htmlStrongElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'strong-element',
match: ast => isElement(ast) && strongElementTags.has(ast.tagName),
match: ast => isStrongElement(ast),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
@@ -96,7 +212,7 @@ export const htmlStrongElementToDeltaMatcher = HtmlASTToDeltaExtension({
export const htmlItalicElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'italic-element',
match: ast => isElement(ast) && italicElementTags.has(ast.tagName),
match: ast => isItalicElement(ast),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
@@ -128,7 +244,7 @@ export const htmlCodeElementToDeltaMatcher = HtmlASTToDeltaExtension({
export const htmlDelElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'del-element',
match: ast => isElement(ast) && ast.tagName === 'del',
match: ast => isLineThroughElement(ast),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
@@ -144,7 +260,7 @@ export const htmlDelElementToDeltaMatcher = HtmlASTToDeltaExtension({
export const htmlUnderlineElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'underline-element',
match: ast => isElement(ast) && ast.tagName === 'u',
match: ast => isUnderlineElement(ast),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];