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 382ff1957..099869f27 100644
--- a/blocksuite/affine/all/src/__tests__/adapters/html.unit.spec.ts
+++ b/blocksuite/affine/all/src/__tests__/adapters/html.unit.spec.ts
@@ -2101,6 +2101,157 @@ describe('html to snapshot', () => {
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
});
+ test('paragraph with br should split into multiple blocks', async () => {
+ const html = template(`
aaa
bbb
ccc
`);
+
+ 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: [],
+ },
+ {
+ type: 'block',
+ id: 'matchesReplaceMap[2]',
+ flavour: 'affine:paragraph',
+ props: {
+ type: 'text',
+ text: {
+ '$blocksuite:internal:text$': true,
+ delta: [{ insert: 'bbb' }],
+ },
+ },
+ children: [],
+ },
+ {
+ type: 'block',
+ id: 'matchesReplaceMap[3]',
+ flavour: 'affine:paragraph',
+ props: {
+ type: 'text',
+ text: {
+ '$blocksuite:internal:text$': true,
+ delta: [{ insert: 'ccc' }],
+ },
+ },
+ children: [],
+ },
+ ],
+ };
+
+ const htmlAdapter = new HtmlAdapter(createJob(), provider);
+ const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({
+ file: html,
+ });
+ expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
+ });
+
+ test('paragraph with br should keep inline styles in each split line', async () => {
+ const html = template(
+ `aaa
bbb
ccc
`
+ );
+
+ 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',
+ attributes: {
+ bold: true,
+ },
+ },
+ ],
+ },
+ },
+ children: [],
+ },
+ {
+ type: 'block',
+ id: 'matchesReplaceMap[2]',
+ flavour: 'affine:paragraph',
+ props: {
+ type: 'text',
+ text: {
+ '$blocksuite:internal:text$': true,
+ delta: [
+ {
+ insert: 'bbb',
+ attributes: {
+ link: 'https://www.google.com/',
+ },
+ },
+ ],
+ },
+ },
+ children: [],
+ },
+ {
+ type: 'block',
+ id: 'matchesReplaceMap[3]',
+ flavour: 'affine:paragraph',
+ props: {
+ type: 'text',
+ text: {
+ '$blocksuite:internal:text$': true,
+ delta: [
+ {
+ insert: 'ccc',
+ attributes: {
+ italic: true,
+ },
+ },
+ ],
+ },
+ },
+ children: [],
+ },
+ ],
+ };
+
+ const htmlAdapter = new HtmlAdapter(createJob(), provider);
+ const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({
+ file: html,
+ });
+ expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
+ });
+
test('nested list', async () => {
const html = template(``);
diff --git a/blocksuite/affine/blocks/paragraph/src/adapters/html.ts b/blocksuite/affine/blocks/paragraph/src/adapters/html.ts
index 6fd51bbba..7dc12c14f 100644
--- a/blocksuite/affine/blocks/paragraph/src/adapters/html.ts
+++ b/blocksuite/affine/blocks/paragraph/src/adapters/html.ts
@@ -37,6 +37,126 @@ const tagsInAncestor = (o: NodeProps, tagNames: Array) => {
return false;
};
+const splitDeltaByNewline = (delta: DeltaInsert[]) => {
+ const lines: DeltaInsert[][] = [[]];
+ const pending = [...delta];
+
+ while (pending.length > 0) {
+ const op = pending.shift();
+ if (!op) continue;
+
+ const insert = op.insert;
+ if (typeof insert !== 'string') {
+ lines[lines.length - 1].push(op);
+ continue;
+ }
+
+ if (!insert.includes('\n')) {
+ if (insert.length === 0) {
+ continue;
+ }
+ lines[lines.length - 1].push(op);
+ continue;
+ }
+
+ const splitIndex = insert.indexOf('\n');
+ const linePart = insert.slice(0, splitIndex);
+ const remainPart = insert.slice(splitIndex + 1);
+ if (linePart.length > 0) {
+ lines[lines.length - 1].push({ ...op, insert: linePart });
+ }
+ lines.push([]);
+ if (remainPart) {
+ pending.unshift({ ...op, insert: remainPart });
+ }
+ }
+
+ return lines;
+};
+
+const hasBlockElementDescendant = (node: HtmlAST): boolean => {
+ if (!HastUtils.isElement(node)) {
+ return false;
+ }
+ return node.children.some(child => {
+ if (!HastUtils.isElement(child)) {
+ return false;
+ }
+ return (
+ (HastUtils.isTagBlock(child.tagName) && child.tagName !== 'br') ||
+ hasBlockElementDescendant(child)
+ );
+ });
+};
+
+const getParagraphDeltas = (
+ node: HtmlAST,
+ delta: DeltaInsert[]
+): DeltaInsert[][] => {
+ if (!HastUtils.isElement(node)) return [delta];
+ if (hasBlockElementDescendant(node)) return [delta];
+
+ const hasBr = !!HastUtils.querySelector(node, 'br');
+ if (!hasBr) return [delta];
+
+ const hasNewline = delta.some(
+ op => typeof op.insert === 'string' && op.insert.includes('\n')
+ );
+ if (!hasNewline) return [delta];
+
+ return splitDeltaByNewline(delta);
+};
+
+const openParagraphBlocks = (
+ deltas: DeltaInsert[][],
+ type: string,
+ // AST walker context from html adapter transform pipeline.
+ walkerContext: any
+) => {
+ for (const delta of deltas) {
+ walkerContext
+ .openNode(
+ {
+ type: 'block',
+ id: nanoid(),
+ flavour: 'affine:paragraph',
+ props: { type, text: { '$blocksuite:internal:text$': true, delta } },
+ children: [],
+ },
+ 'children'
+ )
+ .closeNode();
+ }
+};
+
+const MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY =
+ 'affine:paragraph:multi-emitted-nodes';
+
+const markMultiParagraphEmitted = (walkerContext: any, node: HtmlAST) => {
+ const emittedNodes =
+ (walkerContext.getGlobalContext(
+ MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY
+ ) as WeakSet