diff --git a/packages/blocks/src/__tests__/adapters/html.unit.spec.ts b/packages/blocks/src/__tests__/adapters/html.unit.spec.ts index 00f44bdc68e44..f56bae76bf58a 100644 --- a/packages/blocks/src/__tests__/adapters/html.unit.spec.ts +++ b/packages/blocks/src/__tests__/adapters/html.unit.spec.ts @@ -571,7 +571,112 @@ describe('snapshot to html', () => { ], }; const html = template( - `
  • bbb
  • ccc
  • ddd
` + `` + ); + + const htmlAdapter = new HtmlAdapter(createJob()); + const target = await htmlAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(html); + }); + + test('different list', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:imiLDMKSkx', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + { + type: 'block', + id: 'block:imiLDMKSkx', + flavour: 'affine:list', + props: { + type: 'todo', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + { + type: 'block', + id: 'block:imiLDMKSkx', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + ], + }; + const html = template( + `` ); const htmlAdapter = new HtmlAdapter(createJob()); diff --git a/packages/blocks/src/__tests__/adapters/markdown.unit.spec.ts b/packages/blocks/src/__tests__/adapters/markdown.unit.spec.ts index bf7d85e8c3e91..adf6d36fd785d 100644 --- a/packages/blocks/src/__tests__/adapters/markdown.unit.spec.ts +++ b/packages/blocks/src/__tests__/adapters/markdown.unit.spec.ts @@ -721,6 +721,170 @@ hhh expect(target.file).toEqual(markdown); }); + test('different list', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:m5hvdXHXS2', + flavour: 'affine:page', + version: 2, + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Y4J-oO9h9d', + flavour: 'affine:surface', + version: 5, + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:1Ll22zT992', + flavour: 'affine:note', + version: 1, + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: 'both', + edgeless: { + style: { + borderRadius: 8, + borderSize: 4, + borderStyle: 'solid', + shadowType: '--affine-note-shadow-box', + }, + }, + }, + children: [ + { + type: 'block', + id: 'block:Fd0ZCYB7a4', + flavour: 'affine:list', + version: 1, + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'block:8-GeKDc06x', + flavour: 'affine:list', + version: 1, + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + { + type: 'block', + id: 'block:f0c-9xKaEL', + flavour: 'affine:list', + version: 1, + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + { + type: 'block', + id: 'block:f0c-9xKaEL', + flavour: 'affine:list', + version: 1, + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'block:Fd0ZCYB7a5', + flavour: 'affine:list', + version: 1, + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + ], + }; + + const markdown = `1. aaa + 1. bbb + * ccc + 1. ddd +2. eee +`; + + const mdAdapter = new MarkdownAdapter(createJob()); + const target = await mdAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toEqual(markdown); + }); + test('code inline', async () => { const blockSnapshot: BlockSnapshot = { type: 'block', diff --git a/packages/blocks/src/_common/adapters/html.ts b/packages/blocks/src/_common/adapters/html.ts index 9185afd79e0f1..8c9b4e5ef6dda 100644 --- a/packages/blocks/src/_common/adapters/html.ts +++ b/packages/blocks/src/_common/adapters/html.ts @@ -1,6 +1,6 @@ import type { AffineTextAttributes } from '@blocksuite/affine-components/rich-text'; import type { DeltaInsert } from '@blocksuite/inline'; -import type { Root } from 'hast'; +import type { Element, Root } from 'hast'; import { ColorScheme, @@ -12,6 +12,8 @@ import { getFilenameFromContentDisposition } from '@blocksuite/affine-shared/uti import { sha } from '@blocksuite/global/utils'; import { type AssetsManager, + ASTWalker, + BaseAdapter, type BlockSnapshot, BlockSnapshotSchema, type DocSnapshot, @@ -27,7 +29,6 @@ import { type ToBlockSnapshotPayload, type ToDocSnapshotPayload, } from '@blocksuite/store'; -import { ASTWalker, BaseAdapter } from '@blocksuite/store'; import { collapseWhiteSpace } from 'collapse-white-space'; import rehypeParse from 'rehype-parse'; import rehypeStringify from 'rehype-stringify'; @@ -44,7 +45,7 @@ import { hastQuerySelector, type HtmlAST, } from './hast.js'; -import { fetchable, fetchImage, mergeDeltas } from './utils.js'; +import { fetchable, fetchImage, isNullish, mergeDeltas } from './utils.js'; export type Html = string; @@ -685,6 +686,7 @@ export class HtmlAdapter extends BaseAdapter { const text = (o.node.props.text ?? { delta: [] }) as { delta: DeltaInsert[]; }; + const currentTNode = context.currentNode(); switch (o.node.flavour) { case 'affine:page': { context @@ -935,32 +937,6 @@ export class HtmlAdapter extends BaseAdapter { break; } case 'affine:list': { - context - .openNode( - { - type: 'element', - tagName: 'div', - properties: { - className: ['affine-list-block-container'], - }, - children: [], - }, - 'children' - ) - .openNode( - { - type: 'element', - tagName: o.node.props.type === 'numbered' ? 'ol' : 'ul', - properties: { - style: - o.node.props.type === 'todo' - ? 'list-style-type: none;' - : '', - }, - children: [], - }, - 'children' - ); const liChildren = this._deltaToHast(text.delta); if (o.node.props.type === 'todo') { liChildren.unshift({ @@ -974,36 +950,62 @@ export class HtmlAdapter extends BaseAdapter { { type: 'element', tagName: 'label', - properties: {}, + properties: { + style: 'margin-right: 3px;', + }, children: [], }, ], }); } - context - .openNode( - { - type: 'element', - tagName: 'li', - properties: {}, - children: liChildren, - }, - 'children' - ) - .closeNode() - .closeNode() - .openNode( + // check if the list is of the same type + if ( + context.getNodeContext('affine:list:parent') === o.parent && + currentTNode.type === 'element' && + currentTNode.tagName === + (o.node.props.type === 'numbered' ? 'ol' : 'ul') && + !( + Array.isArray(currentTNode.properties.className) && + currentTNode.properties.className.includes('todo-list') + ) === + isNullish( + o.node.props.type === 'todo' + ? (o.node.props.checked as boolean) + : undefined + ) + ) { + // if true, add the list item to the list + } else { + // if false, create a new list + context.openNode( { type: 'element', - tagName: 'div', + tagName: o.node.props.type === 'numbered' ? 'ol' : 'ul', properties: { - className: ['affine-block-children-container'], - style: 'padding-left: 26px;', + style: + o.node.props.type === 'todo' + ? 'list-style-type: none; padding-inline-start: 18px;' + : null, + className: [o.node.props.type + '-list'], }, children: [], }, 'children' ); + context.setNodeContext('affine:list:parent', o.parent); + } + + context.openNode( + { + type: 'element', + tagName: 'li', + properties: { + className: ['affine-list-block-container'], + }, + children: liChildren, + }, + 'children' + ); break; } case 'affine:divider': { @@ -1083,7 +1085,34 @@ export class HtmlAdapter extends BaseAdapter { break; } case 'affine:list': { - context.closeNode().closeNode(); + const currentTNode = context.currentNode() as Element; + const previousTNode = context.previousNode() as Element; + if ( + context.getPreviousNodeContext('affine:list:parent') === o.parent && + currentTNode.tagName === 'li' && + previousTNode.tagName === + (o.node.props.type === 'numbered' ? 'ol' : 'ul') && + !( + Array.isArray(previousTNode.properties.className) && + previousTNode.properties.className.includes('todo-list') + ) === + isNullish( + o.node.props.type === 'todo' + ? (o.node.props.checked as boolean) + : undefined + ) + ) { + context.closeNode(); + if ( + o.next?.flavour !== 'affine:list' || + o.next.props.type !== o.node.props.type + ) { + // If the next node is not a list or different type of list, close the list + context.closeNode(); + } + } else { + context.closeNode().closeNode(); + } break; } } diff --git a/packages/blocks/src/_common/adapters/markdown.ts b/packages/blocks/src/_common/adapters/markdown.ts index f6392eb4dadeb..aa9fa95e66307 100644 --- a/packages/blocks/src/_common/adapters/markdown.ts +++ b/packages/blocks/src/_common/adapters/markdown.ts @@ -520,8 +520,6 @@ export class MarkdownAdapter extends BaseAdapter { } case 'affine:list': { // check if the list is of the same type - // if true, add the list item to the list - // if false, create a new list if ( context.getNodeContext('affine:list:parent') === o.parent && currentTNode.type === 'list' && @@ -533,28 +531,9 @@ export class MarkdownAdapter extends BaseAdapter { : undefined ) ) { - context - .openNode( - { - type: 'listItem', - checked: - o.node.props.type === 'todo' - ? (o.node.props.checked as boolean) - : undefined, - spread: false, - children: [], - }, - 'children' - ) - .openNode( - { - type: 'paragraph', - children: this._deltaToMdAST(text.delta), - }, - 'children' - ) - .closeNode(); + // if true, add the list item to the list } else { + // if false, create a new list context .openNode( { @@ -565,28 +544,29 @@ export class MarkdownAdapter extends BaseAdapter { }, 'children' ) - .setNodeContext('affine:list:parent', o.parent) - .openNode( - { - type: 'listItem', - checked: - o.node.props.type === 'todo' - ? (o.node.props.checked as boolean) - : undefined, - spread: false, - children: [], - }, - 'children' - ) - .openNode( - { - type: 'paragraph', - children: this._deltaToMdAST(text.delta), - }, - 'children' - ) - .closeNode(); + .setNodeContext('affine:list:parent', o.parent); } + context + .openNode( + { + type: 'listItem', + checked: + o.node.props.type === 'todo' + ? (o.node.props.checked as boolean) + : undefined, + spread: false, + children: [], + }, + 'children' + ) + .openNode( + { + type: 'paragraph', + children: this._deltaToMdAST(text.delta), + }, + 'children' + ) + .closeNode(); break; } case 'affine:divider': { @@ -877,8 +857,11 @@ export class MarkdownAdapter extends BaseAdapter { ) ) { context.closeNode(); - if (o.next?.flavour !== 'affine:list') { - // If the next node is not a list, close the list + if ( + o.next?.flavour !== 'affine:list' || + o.next.props.type !== o.node.props.type + ) { + // If the next node is not a list or different type of list, close the list context.closeNode(); } } else { diff --git a/packages/framework/store/src/adapter/base.ts b/packages/framework/store/src/adapter/base.ts index c279bcfd52795..7b1a81e55a855 100644 --- a/packages/framework/store/src/adapter/base.ts +++ b/packages/framework/store/src/adapter/base.ts @@ -1,4 +1,4 @@ -import { assertEquals } from '@blocksuite/global/utils'; +import { BlockSuiteError } from '@blocksuite/global/exceptions'; import type { Doc } from '../store/index.js'; import type { AssetsManager } from '../transformer/assets.js'; @@ -48,6 +48,20 @@ export type ToSliceSnapshotPayload = { assets?: AssetsManager; }; +export function wrapFakeNote(snapshot: SliceSnapshot) { + if (snapshot.content[0]?.flavour !== 'affine:note') { + snapshot.content = [ + { + type: 'block', + id: '', + flavour: 'affine:note', + props: {}, + children: snapshot.content, + }, + ]; + } +} + export abstract class BaseAdapter { job: Job; @@ -105,6 +119,7 @@ export abstract class BaseAdapter { try { const sliceSnapshot = await this.job.sliceToSnapshot(slice); if (!sliceSnapshot) return; + wrapFakeNote(sliceSnapshot); return await this.fromSliceSnapshot({ snapshot: sliceSnapshot, assets: this.job.assetsManager, @@ -280,7 +295,10 @@ export class ASTWalker { walk = async (oNode: ONode, tNode: TNode) => { this.context.openNode(tNode); await this._visit({ node: oNode, parent: null, prop: null, index: null }); - assertEquals(this.context.stack.length, 1, 'There are unclosed nodes'); + if (this.context.stack.length !== 1) { + console.error(this.context.stack.map(n => n.node)); + throw new BlockSuiteError(1, 'There are unclosed nodes'); + } return this.context.currentNode(); }; diff --git a/tests/snapshots/clipboard/clipboard.spec.ts/clipboard-copy-nested-items-clipboard.json b/tests/snapshots/clipboard/clipboard.spec.ts/clipboard-copy-nested-items-clipboard.json index 9c082a376a85d..130047281934d 100644 --- a/tests/snapshots/clipboard/clipboard.spec.ts/clipboard-copy-nested-items-clipboard.json +++ b/tests/snapshots/clipboard/clipboard.spec.ts/clipboard-copy-nested-items-clipboard.json @@ -1,24 +1,13 @@ [ { "type": "block", - "id": "2", - "flavour": "affine:paragraph", - "version": 1, - "props": { - "type": "text", - "text": { - "$blocksuite:internal:text$": true, - "delta": [ - { - "insert": "bc" - } - ] - } - }, + "id": "", + "flavour": "affine:note", + "props": {}, "children": [ { "type": "block", - "id": "3", + "id": "2", "flavour": "affine:paragraph", "version": 1, "props": { @@ -27,12 +16,31 @@ "$blocksuite:internal:text$": true, "delta": [ { - "insert": "d" + "insert": "bc" } ] } }, - "children": [] + "children": [ + { + "type": "block", + "id": "3", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "text", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "d" + } + ] + } + }, + "children": [] + } + ] } ] } diff --git a/tests/snapshots/clipboard/clipboard.spec.ts/clipboard-copy-nested-items-clipboard2.json b/tests/snapshots/clipboard/clipboard.spec.ts/clipboard-copy-nested-items-clipboard2.json index 9bc963987482e..eff50e7ad92dc 100644 --- a/tests/snapshots/clipboard/clipboard.spec.ts/clipboard-copy-nested-items-clipboard2.json +++ b/tests/snapshots/clipboard/clipboard.spec.ts/clipboard-copy-nested-items-clipboard2.json @@ -1,38 +1,46 @@ [ { "type": "block", - "id": "4", - "flavour": "affine:paragraph", - "version": 1, - "props": { - "type": "text", - "text": { - "$blocksuite:internal:text$": true, - "delta": [ - { - "insert": "hi" + "id": "", + "flavour": "affine:note", + "props": {}, + "children": [ + { + "type": "block", + "id": "4", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "text", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "hi" + } + ] } - ] - } - }, - "children": [] - }, - { - "type": "block", - "id": "5", - "flavour": "affine:paragraph", - "version": 1, - "props": { - "type": "text", - "text": { - "$blocksuite:internal:text$": true, - "delta": [ - { - "insert": "j" + }, + "children": [] + }, + { + "type": "block", + "id": "5", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "text", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "j" + } + ] } - ] + }, + "children": [] } - }, - "children": [] + ] } ] \ No newline at end of file diff --git a/tests/snapshots/clipboard/list.spec.ts/copy-a-nested-list-by-clicking-button-the-clipboard-data-should-be-complete-clipboard.html b/tests/snapshots/clipboard/list.spec.ts/copy-a-nested-list-by-clicking-button-the-clipboard-data-should-be-complete-clipboard.html index 7a74fc0d0ac3a..b74b581cefa8d 100644 --- a/tests/snapshots/clipboard/list.spec.ts/copy-a-nested-list-by-clicking-button-the-clipboard-data-should-be-complete-clipboard.html +++ b/tests/snapshots/clipboard/list.spec.ts/copy-a-nested-list-by-clicking-button-the-clipboard-data-should-be-complete-clipboard.html @@ -1,20 +1,11 @@ -
-
    -
  • aaa
  • -
-
-
-
    -
  • bbb
  • -
-
-
-
    -
  • ccc
  • -
-
-
-
-
-
-
\ No newline at end of file +
    +
  • aaa +
      +
    • bbb +
        +
      • ccc
      • +
      +
    • +
    +
  • +
\ No newline at end of file diff --git a/tests/snapshots/clipboard/list.spec.ts/copy-a-nested-list-by-clicking-button-the-clipboard-data-should-be-complete-clipboard.json b/tests/snapshots/clipboard/list.spec.ts/copy-a-nested-list-by-clicking-button-the-clipboard-data-should-be-complete-clipboard.json index 901af3391b2b1..43b78e52be343 100644 --- a/tests/snapshots/clipboard/list.spec.ts/copy-a-nested-list-by-clicking-button-the-clipboard-data-should-be-complete-clipboard.json +++ b/tests/snapshots/clipboard/list.spec.ts/copy-a-nested-list-by-clicking-button-the-clipboard-data-should-be-complete-clipboard.json @@ -1,27 +1,13 @@ [ { "type": "block", - "id": "3", - "flavour": "affine:list", - "version": 1, - "props": { - "type": "bulleted", - "text": { - "$blocksuite:internal:text$": true, - "delta": [ - { - "insert": "aaa" - } - ] - }, - "checked": false, - "collapsed": false, - "order": null - }, + "id": "", + "flavour": "affine:note", + "props": {}, "children": [ { "type": "block", - "id": "4", + "id": "3", "flavour": "affine:list", "version": 1, "props": { @@ -30,7 +16,7 @@ "$blocksuite:internal:text$": true, "delta": [ { - "insert": "bbb" + "insert": "aaa" } ] }, @@ -41,7 +27,7 @@ "children": [ { "type": "block", - "id": "5", + "id": "4", "flavour": "affine:list", "version": 1, "props": { @@ -50,7 +36,7 @@ "$blocksuite:internal:text$": true, "delta": [ { - "insert": "ccc" + "insert": "bbb" } ] }, @@ -58,7 +44,29 @@ "collapsed": false, "order": null }, - "children": [] + "children": [ + { + "type": "block", + "id": "5", + "flavour": "affine:list", + "version": 1, + "props": { + "type": "bulleted", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "ccc" + } + ] + }, + "checked": false, + "collapsed": false, + "order": null + }, + "children": [] + } + ] } ] }