diff --git a/blocksuite/affine/all/src/__tests__/adapters/html.unit.spec.ts b/blocksuite/affine/all/src/__tests__/adapters/html.unit.spec.ts index f2aa7b429..1fb0338f9 100644 --- a/blocksuite/affine/all/src/__tests__/adapters/html.unit.spec.ts +++ b/blocksuite/affine/all/src/__tests__/adapters/html.unit.spec.ts @@ -2697,4 +2697,335 @@ describe('html to snapshot', () => { }); expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); }); + + test('block level element in b should not be treated as inline', async () => { + const html = template(`

aaa

`); + 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(`aaa`); + 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( + `

aaaaaabbbbbbccccccddd

` + ); + 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( + `

aaaaaabbbbbbccc

` + ); + 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( + `

aaaaaabbb

` + ); + 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( + `

aaaaaabbb

` + ); + 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); + }); }); diff --git a/blocksuite/affine/inlines/preset/src/adapters/html/html-inline.ts b/blocksuite/affine/inlines/preset/src/adapters/html/html-inline.ts index 291cb3332..a05000924 100644 --- a/blocksuite/affine/inlines/preset/src/adapters/html/html-inline.ts +++ b/blocksuite/affine/inlines/preset/src/adapters/html/html-inline.ts @@ -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 , 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 + * Hello + * Hello + * Hello + * Hello + * ``` + */ +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 + 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 , 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 + * Hello + * Hello + * Hello + * ``` + */ +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 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 + * Hello + * Hello + * ``` + */ +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 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 + * Hello + * Hello + * ``` + */ +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 Hello + * @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 []; diff --git a/blocksuite/affine/shared/src/__tests__/adapters/html/rehype-plugins/inline-to-block.unit.spec.ts b/blocksuite/affine/shared/src/__tests__/adapters/html/rehype-plugins/inline-to-block.unit.spec.ts new file mode 100644 index 000000000..7a24f9324 --- /dev/null +++ b/blocksuite/affine/shared/src/__tests__/adapters/html/rehype-plugins/inline-to-block.unit.spec.ts @@ -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 = 'Hello World'; + const output = process(input); + expect(output).toBe('Hello World'); + }); + + it('should transform inline elements containing block children', () => { + const input = '

Hello World

'; + const output = process(input); + expect(output).toBe('

Hello World

'); + }); + + it('should preserve existing attributes when transforming', () => { + const input = '

Hello World

'; + const output = process(input); + expect(output).toBe( + '

Hello World

' + ); + }); + + it('should handle multiple block elements within inline element', () => { + const input = '

First

Second

Third

'; + const output = process(input); + expect(output).toBe( + '

First

Second

Third

' + ); + }); + + it('should handle mixed content (text and block elements)', () => { + const input = 'Text before

Block element

Text after
'; + const output = process(input); + expect(output).toBe( + '
Text before

Block element

Text after
' + ); + }); + + it('should handle complex nested structures', () => { + const input = '

Nested inline content

'; + const output = process(input); + expect(output).toBe( + '

Nested inline content

' + ); + }); +}); diff --git a/blocksuite/affine/shared/src/adapters/html/html.ts b/blocksuite/affine/shared/src/adapters/html/html.ts index 3a3b61da0..158b40762 100644 --- a/blocksuite/affine/shared/src/adapters/html/html.ts +++ b/blocksuite/affine/shared/src/adapters/html/html.ts @@ -40,6 +40,7 @@ import { HtmlDeltaConverter, InlineDeltaToHtmlAdapterMatcherIdentifier, } from './delta-converter'; +import { rehypeInlineToBlock } from './rehype-plugins'; export type Html = string; @@ -195,7 +196,9 @@ export class HtmlAdapter extends BaseAdapter { } 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( diff --git a/blocksuite/affine/shared/src/adapters/html/rehype-plugins/index.ts b/blocksuite/affine/shared/src/adapters/html/rehype-plugins/index.ts new file mode 100644 index 000000000..3965fc77e --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/html/rehype-plugins/index.ts @@ -0,0 +1 @@ +export * from './inline-to-block'; diff --git a/blocksuite/affine/shared/src/adapters/html/rehype-plugins/inline-to-block.ts b/blocksuite/affine/shared/src/adapters/html/rehype-plugins/inline-to-block.ts new file mode 100644 index 000000000..3d48356e9 --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/html/rehype-plugins/inline-to-block.ts @@ -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 tag + * To handle this case, we need to convert the tag to a
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, + }; + } + } + }); + }; +};