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:
@@ -2697,4 +2697,335 @@ describe('html to snapshot', () => {
|
|||||||
});
|
});
|
||||||
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
|
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('block level element in b should not be treated as inline', async () => {
|
||||||
|
const html = template(`<b><p><span>aaa</span></p></b>`);
|
||||||
|
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: 'aaa',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const htmlAdapter = new HtmlAdapter(createJob(), provider);
|
||||||
|
const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({
|
||||||
|
file: html,
|
||||||
|
});
|
||||||
|
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('strong element', () => {
|
||||||
|
test('should not be bold when font-weight is normal', async () => {
|
||||||
|
const html = template(`<span style="font-weight: normal;">aaa</span>`);
|
||||||
|
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: 'aaa',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const htmlAdapter = new HtmlAdapter(createJob(), provider);
|
||||||
|
const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({
|
||||||
|
file: html,
|
||||||
|
});
|
||||||
|
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be bold when font-weight is bold or 500-900 ', async () => {
|
||||||
|
const html = template(
|
||||||
|
`<p><span style="font-weight: bold;">aaa</span><span style="font-weight: 100;">aaa</span><span style="font-weight: 500;">bbb</span><span style="font-weight: 200;">bbb</span><span style="font-weight: 600;">ccc</span><span style="font-weight: 300;">ccc</span><span style="font-weight: 700;">ddd</span></p>`
|
||||||
|
);
|
||||||
|
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: [
|
||||||
|
{
|
||||||
|
attributes: {
|
||||||
|
bold: true,
|
||||||
|
},
|
||||||
|
insert: 'aaa',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
insert: 'aaa',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attributes: {
|
||||||
|
bold: true,
|
||||||
|
},
|
||||||
|
insert: 'bbb',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
insert: 'bbb',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attributes: {
|
||||||
|
bold: true,
|
||||||
|
},
|
||||||
|
insert: 'ccc',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
insert: 'ccc',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attributes: {
|
||||||
|
bold: true,
|
||||||
|
},
|
||||||
|
insert: 'ddd',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const htmlAdapter = new HtmlAdapter(createJob(), provider);
|
||||||
|
const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({
|
||||||
|
file: html,
|
||||||
|
});
|
||||||
|
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be italic when tag is i or em or span with style font-style: italic', async () => {
|
||||||
|
const html = template(
|
||||||
|
`<p><i>aaa</i><span>aaa</span><em>bbb</em><span>bbb</span><span style="font-style: italic;">ccc</span></p>`
|
||||||
|
);
|
||||||
|
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: [
|
||||||
|
{
|
||||||
|
attributes: {
|
||||||
|
italic: true,
|
||||||
|
},
|
||||||
|
insert: 'aaa',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
insert: 'aaa',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attributes: {
|
||||||
|
italic: true,
|
||||||
|
},
|
||||||
|
insert: 'bbb',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
insert: 'bbb',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attributes: {
|
||||||
|
italic: true,
|
||||||
|
},
|
||||||
|
insert: 'ccc',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const htmlAdapter = new HtmlAdapter(createJob(), provider);
|
||||||
|
const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({
|
||||||
|
file: html,
|
||||||
|
});
|
||||||
|
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be underline when tag is u or span with style text-decoration: underline', async () => {
|
||||||
|
const html = template(
|
||||||
|
`<p><u>aaa</u><span>aaa</span><span style="text-decoration: underline;">bbb</span></p>`
|
||||||
|
);
|
||||||
|
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: [
|
||||||
|
{
|
||||||
|
attributes: {
|
||||||
|
underline: true,
|
||||||
|
},
|
||||||
|
insert: 'aaa',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
insert: 'aaa',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attributes: {
|
||||||
|
underline: true,
|
||||||
|
},
|
||||||
|
insert: 'bbb',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const htmlAdapter = new HtmlAdapter(createJob(), provider);
|
||||||
|
const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({
|
||||||
|
file: html,
|
||||||
|
});
|
||||||
|
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be strike when tag is del or span with style text-decoration: line-through', async () => {
|
||||||
|
const html = template(
|
||||||
|
`<p><del>aaa</del><span>aaa</span><span style="text-decoration: line-through;">bbb</span></p>`
|
||||||
|
);
|
||||||
|
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: [
|
||||||
|
{
|
||||||
|
attributes: {
|
||||||
|
strike: true,
|
||||||
|
},
|
||||||
|
insert: 'aaa',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
insert: 'aaa',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attributes: {
|
||||||
|
strike: true,
|
||||||
|
},
|
||||||
|
insert: 'bbb',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const htmlAdapter = new HtmlAdapter(createJob(), provider);
|
||||||
|
const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({
|
||||||
|
file: html,
|
||||||
|
});
|
||||||
|
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,6 +32,122 @@ const listElementTags = new Set(['ol', 'ul']);
|
|||||||
const strongElementTags = new Set(['strong', 'b']);
|
const strongElementTags = new Set(['strong', 'b']);
|
||||||
const italicElementTags = new Set(['i', 'em']);
|
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({
|
export const htmlTextToDeltaMatcher = HtmlASTToDeltaExtension({
|
||||||
name: 'text',
|
name: 'text',
|
||||||
match: ast => ast.type === 'text',
|
match: ast => ast.type === 'text',
|
||||||
@@ -59,7 +175,7 @@ export const htmlTextToDeltaMatcher = HtmlASTToDeltaExtension({
|
|||||||
|
|
||||||
export const htmlTextLikeElementToDeltaMatcher = HtmlASTToDeltaExtension({
|
export const htmlTextLikeElementToDeltaMatcher = HtmlASTToDeltaExtension({
|
||||||
name: 'text-like-element',
|
name: 'text-like-element',
|
||||||
match: ast => isElement(ast) && textLikeElementTags.has(ast.tagName),
|
match: ast => isTextLikeElement(ast),
|
||||||
toDelta: (ast, context) => {
|
toDelta: (ast, context) => {
|
||||||
if (!isElement(ast)) {
|
if (!isElement(ast)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -80,7 +196,7 @@ export const htmlListToDeltaMatcher = HtmlASTToDeltaExtension({
|
|||||||
|
|
||||||
export const htmlStrongElementToDeltaMatcher = HtmlASTToDeltaExtension({
|
export const htmlStrongElementToDeltaMatcher = HtmlASTToDeltaExtension({
|
||||||
name: 'strong-element',
|
name: 'strong-element',
|
||||||
match: ast => isElement(ast) && strongElementTags.has(ast.tagName),
|
match: ast => isStrongElement(ast),
|
||||||
toDelta: (ast, context) => {
|
toDelta: (ast, context) => {
|
||||||
if (!isElement(ast)) {
|
if (!isElement(ast)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -96,7 +212,7 @@ export const htmlStrongElementToDeltaMatcher = HtmlASTToDeltaExtension({
|
|||||||
|
|
||||||
export const htmlItalicElementToDeltaMatcher = HtmlASTToDeltaExtension({
|
export const htmlItalicElementToDeltaMatcher = HtmlASTToDeltaExtension({
|
||||||
name: 'italic-element',
|
name: 'italic-element',
|
||||||
match: ast => isElement(ast) && italicElementTags.has(ast.tagName),
|
match: ast => isItalicElement(ast),
|
||||||
toDelta: (ast, context) => {
|
toDelta: (ast, context) => {
|
||||||
if (!isElement(ast)) {
|
if (!isElement(ast)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -128,7 +244,7 @@ export const htmlCodeElementToDeltaMatcher = HtmlASTToDeltaExtension({
|
|||||||
|
|
||||||
export const htmlDelElementToDeltaMatcher = HtmlASTToDeltaExtension({
|
export const htmlDelElementToDeltaMatcher = HtmlASTToDeltaExtension({
|
||||||
name: 'del-element',
|
name: 'del-element',
|
||||||
match: ast => isElement(ast) && ast.tagName === 'del',
|
match: ast => isLineThroughElement(ast),
|
||||||
toDelta: (ast, context) => {
|
toDelta: (ast, context) => {
|
||||||
if (!isElement(ast)) {
|
if (!isElement(ast)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -144,7 +260,7 @@ export const htmlDelElementToDeltaMatcher = HtmlASTToDeltaExtension({
|
|||||||
|
|
||||||
export const htmlUnderlineElementToDeltaMatcher = HtmlASTToDeltaExtension({
|
export const htmlUnderlineElementToDeltaMatcher = HtmlASTToDeltaExtension({
|
||||||
name: 'underline-element',
|
name: 'underline-element',
|
||||||
match: ast => isElement(ast) && ast.tagName === 'u',
|
match: ast => isUnderlineElement(ast),
|
||||||
toDelta: (ast, context) => {
|
toDelta: (ast, context) => {
|
||||||
if (!isElement(ast)) {
|
if (!isElement(ast)) {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import rehypeParse from 'rehype-parse';
|
||||||
|
import rehypeStringify from 'rehype-stringify';
|
||||||
|
import { unified } from 'unified';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { rehypeInlineToBlock } from '../../../../adapters/html/rehype-plugins/inline-to-block';
|
||||||
|
|
||||||
|
describe('rehypeInlineToBlock', () => {
|
||||||
|
const process = (html: string) => {
|
||||||
|
return unified()
|
||||||
|
.use(rehypeParse, { fragment: true })
|
||||||
|
.use(rehypeInlineToBlock)
|
||||||
|
.use(rehypeStringify)
|
||||||
|
.processSync(html)
|
||||||
|
.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should not transform inline elements without block children', () => {
|
||||||
|
const input = '<b>Hello World</b>';
|
||||||
|
const output = process(input);
|
||||||
|
expect(output).toBe('<b>Hello World</b>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should transform inline elements containing block children', () => {
|
||||||
|
const input = '<b><p>Hello World</p></b>';
|
||||||
|
const output = process(input);
|
||||||
|
expect(output).toBe('<div data-original-tag="b"><p>Hello World</p></div>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve existing attributes when transforming', () => {
|
||||||
|
const input = '<b class="test" id="demo"><p>Hello World</p></b>';
|
||||||
|
const output = process(input);
|
||||||
|
expect(output).toBe(
|
||||||
|
'<div class="test" id="demo" data-original-tag="b"><p>Hello World</p></div>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple block elements within inline element', () => {
|
||||||
|
const input = '<b><p>First</p><div>Second</div><h1>Third</h1></b>';
|
||||||
|
const output = process(input);
|
||||||
|
expect(output).toBe(
|
||||||
|
'<div data-original-tag="b"><p>First</p><div>Second</div><h1>Third</h1></div>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed content (text and block elements)', () => {
|
||||||
|
const input = '<b>Text before<p>Block element</p>Text after</b>';
|
||||||
|
const output = process(input);
|
||||||
|
expect(output).toBe(
|
||||||
|
'<div data-original-tag="b">Text before<p>Block element</p>Text after</div>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex nested structures', () => {
|
||||||
|
const input = '<b><div><p>Nested <b>inline</b> content</p></div></b>';
|
||||||
|
const output = process(input);
|
||||||
|
expect(output).toBe(
|
||||||
|
'<div data-original-tag="b"><div><p>Nested <b>inline</b> content</p></div></div>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
HtmlDeltaConverter,
|
HtmlDeltaConverter,
|
||||||
InlineDeltaToHtmlAdapterMatcherIdentifier,
|
InlineDeltaToHtmlAdapterMatcherIdentifier,
|
||||||
} from './delta-converter';
|
} from './delta-converter';
|
||||||
|
import { rehypeInlineToBlock } from './rehype-plugins';
|
||||||
|
|
||||||
export type Html = string;
|
export type Html = string;
|
||||||
|
|
||||||
@@ -195,7 +196,9 @@ export class HtmlAdapter extends BaseAdapter<Html> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _htmlToAst(html: Html) {
|
private _htmlToAst(html: Html) {
|
||||||
return unified().use(rehypeParse).parse(html);
|
const processor = unified().use(rehypeParse).use(rehypeInlineToBlock);
|
||||||
|
const ast = processor.parse(html);
|
||||||
|
return processor.runSync(ast);
|
||||||
}
|
}
|
||||||
|
|
||||||
override async fromBlockSnapshot(
|
override async fromBlockSnapshot(
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './inline-to-block';
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { Root } from 'hast';
|
||||||
|
import type { Plugin } from 'unified';
|
||||||
|
import { visit } from 'unist-util-visit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The content copied from google docs will be wrapped in <b> tag
|
||||||
|
* To handle this case, we need to convert the <b> tag to a <div> tag
|
||||||
|
*/
|
||||||
|
const inlineElements = new Set(['b']);
|
||||||
|
|
||||||
|
const blockElements = new Set([
|
||||||
|
'div',
|
||||||
|
'p',
|
||||||
|
'h1',
|
||||||
|
'h2',
|
||||||
|
'h3',
|
||||||
|
'h4',
|
||||||
|
'h5',
|
||||||
|
'h6',
|
||||||
|
'ul',
|
||||||
|
'ol',
|
||||||
|
'li',
|
||||||
|
'blockquote',
|
||||||
|
'pre',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const rehypeInlineToBlock: Plugin<[], Root> = () => {
|
||||||
|
return tree => {
|
||||||
|
visit(tree, 'element', node => {
|
||||||
|
// Check if the current node is an inline element
|
||||||
|
if (inlineElements.has(node.tagName)) {
|
||||||
|
// Check if the node has a block element child
|
||||||
|
const hasBlockChild = node.children.some(
|
||||||
|
child => child.type === 'element' && blockElements.has(child.tagName)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasBlockChild) {
|
||||||
|
const originalTag = node.tagName;
|
||||||
|
// Convert the inline element to a div
|
||||||
|
node.tagName = 'div';
|
||||||
|
// Keep the original properties
|
||||||
|
node.properties = {
|
||||||
|
...node.properties,
|
||||||
|
'data-original-tag': originalTag,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user