diff --git a/blocksuite/affine/shared/src/__tests__/commands/block-crud/get-first-block.unit.spec.ts b/blocksuite/affine/shared/src/__tests__/commands/block-crud/get-first-block.unit.spec.ts new file mode 100644 index 0000000000..45abde1ed3 --- /dev/null +++ b/blocksuite/affine/shared/src/__tests__/commands/block-crud/get-first-block.unit.spec.ts @@ -0,0 +1,230 @@ +/** + * @vitest-environment happy-dom + */ +import { describe, expect, it } from 'vitest'; + +import { getFirstBlockCommand } from '../../../commands/block-crud/get-first-content-block'; +import { affine } from '../../helpers/affine-template'; + +describe('commands/block-crud', () => { + describe('getFirstBlockCommand', () => { + it('should return null when root is not exists', () => { + const host = affine``; + + const [_, { firstBlock }] = host.command.exec(getFirstBlockCommand, { + role: 'content', + root: undefined, + }); + + expect(firstBlock).toBeNull(); + }); + + it('should return first block with content role when found', () => { + const host = affine` + + + First Paragraph + Second Paragraph + + + First Paragraph + Second Paragraph + + + `; + + const [_, { firstBlock }] = host.command.exec(getFirstBlockCommand, { + role: 'hub', + root: undefined, + }); + + expect(firstBlock?.id).toBe('note-1'); + }); + + it('should return first block with any role in the array when found', () => { + const host = affine` + + + First Paragraph + Second Paragraph + + + First Paragraph + Second Paragraph + + + `; + + const [_, { firstBlock }] = host.command.exec(getFirstBlockCommand, { + role: ['hub', 'content'], + root: undefined, + }); + + expect(firstBlock?.id).toBe('note-1'); + }); + + it('should return first block with specified flavour when found', () => { + const host = affine` + + + Paragraph + List Item + + + `; + + const note = host.doc.getBlock('note-1')?.model; + + const [_, { firstBlock }] = host.command.exec(getFirstBlockCommand, { + flavour: 'affine:list', + root: note, + }); + + expect(firstBlock?.id).toBe('list-1'); + }); + + it('should return first block with any flavour in the array when found', () => { + const host = affine` + + + Paragraph + List Item + + + `; + + const note = host.doc.getBlock('note-1')?.model; + + const [_, { firstBlock }] = host.command.exec(getFirstBlockCommand, { + flavour: ['affine:list', 'affine:code'], + root: note, + }); + + expect(firstBlock?.id).toBe('list-1'); + }); + + it('should return first block matching both role and flavour when both specified', () => { + const host = affine` + + + Content Paragraph + Content List + hub Paragraph + + + `; + + const note = host.doc.getBlock('note-1')?.model; + const [_, { firstBlock }] = host.command.exec(getFirstBlockCommand, { + role: 'content', + flavour: 'affine:list', + root: note, + }); + + expect(firstBlock?.id).toBe('list-1'); + }); + + it('should return first block with default roles when role not specified', () => { + const host = affine` + + + hub Paragraph + Content Paragraph + Hub Paragraph + + + `; + + const [_, { firstBlock }] = host.command.exec(getFirstBlockCommand, { + root: undefined, + }); + + expect(firstBlock?.id).toBe('note-1'); + }); + + it('should return first block with specified role when found', () => { + const host = affine` + + + Content Paragraph + hub Paragraph + Database + + + `; + + const note = host.doc.getBlock('note-1')?.model; + + const [_, { firstBlock }] = host.command.exec(getFirstBlockCommand, { + role: 'hub', + root: note, + }); + + expect(firstBlock?.id).toBe('database-1'); + }); + + it('should return null when no blocks with specified role are found in children', () => { + const host = affine` + + + Content Paragraph + Another Content Paragraph + + + `; + + const note = host.doc.getBlock('note-1')?.model; + + const [_, { firstBlock }] = host.command.exec(getFirstBlockCommand, { + role: 'hub', + root: note, + }); + + expect(firstBlock).toBeNull(); + }); + + it('should return null when no blocks with specified flavour are found in children', () => { + const host = affine` + + + Paragraph + Another Paragraph + + + `; + + const note = host.doc.getBlock('note-1')?.model; + + const [_, { firstBlock }] = host.command.exec(getFirstBlockCommand, { + flavour: 'affine:list', + root: note, + }); + + expect(firstBlock).toBeNull(); + }); + + it('should return first block with specified role within specified root subtree', () => { + const host = affine` + + + 1-1 Content + 1-2 hub + + + 2-1 hub + 2-2 Content + + + `; + + const note = host.doc.getBlock('note-2')?.model; + + const [_, { firstBlock }] = host.command.exec(getFirstBlockCommand, { + role: 'content', + root: note, + }); + + expect(firstBlock?.id).toBe('paragraph-2-1'); + }); + }); +}); diff --git a/blocksuite/affine/shared/src/__tests__/commands/block-crud/get-first-content-block.unit.spec.ts b/blocksuite/affine/shared/src/__tests__/commands/block-crud/get-first-content-block.unit.spec.ts deleted file mode 100644 index 34652502b0..0000000000 --- a/blocksuite/affine/shared/src/__tests__/commands/block-crud/get-first-content-block.unit.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * @vitest-environment happy-dom - */ -import { describe, expect, it } from 'vitest'; - -import { getFirstContentBlockCommand } from '../../../commands/block-crud/get-first-content-block'; -import { affine } from '../../helpers/affine-template'; - -describe('commands/block-crud', () => { - describe('getFirstContentBlockCommand', () => { - it('should return null when root is not provided and no note block exists', () => { - const host = affine``; - - const [_, { firstBlock }] = host.command.exec( - getFirstContentBlockCommand, - { - root: undefined, - std: { - host, - } as any, - } - ); - - expect(firstBlock).toBeNull(); - }); - - it('should return first content block when found', () => { - const host = affine` - - - First Paragraph - Second Paragraph - - - `; - - const [_, { firstBlock }] = host.command.exec( - getFirstContentBlockCommand, - { - root: undefined, - } - ); - - expect(firstBlock?.id).toBe('paragraph-1'); - }); - - it('should return null when no content blocks are found in children', () => { - const host = affine` - - - - - `; - - const [_, { firstBlock }] = host.command.exec( - getFirstContentBlockCommand, - {} - ); - - expect(firstBlock).toBeNull(); - }); - - it('should return first content block within specified root subtree', () => { - const host = affine` - - - 1-1 Paragraph - 1-2 Paragraph - - - 2-1 Paragraph - 2-2 Paragraph - - - `; - - const noteBlock = host.doc.getBlock('note-2')?.model; - - const [_, { firstBlock }] = host.command.exec( - getFirstContentBlockCommand, - { - root: noteBlock, - } - ); - - expect(firstBlock?.id).toBe('paragraph-2-1'); - }); - }); -}); diff --git a/blocksuite/affine/shared/src/__tests__/commands/block-crud/get-last-block.unit.spec.ts b/blocksuite/affine/shared/src/__tests__/commands/block-crud/get-last-block.unit.spec.ts new file mode 100644 index 0000000000..702d7a079e --- /dev/null +++ b/blocksuite/affine/shared/src/__tests__/commands/block-crud/get-last-block.unit.spec.ts @@ -0,0 +1,230 @@ +/** + * @vitest-environment happy-dom + */ +import { describe, expect, it } from 'vitest'; + +import { getLastBlockCommand } from '../../../commands/block-crud/get-last-content-block'; +import { affine } from '../../helpers/affine-template'; + +describe('commands/block-crud', () => { + describe('getLastBlockCommand', () => { + it('should return null when root is not exists', () => { + const host = affine``; + + const [_, { lastBlock }] = host.command.exec(getLastBlockCommand, { + role: 'content', + root: undefined, + }); + + expect(lastBlock).toBeNull(); + }); + + it('should return last block with content role when found', () => { + const host = affine` + + + First Paragraph + Second Paragraph + + + First Paragraph + Second Paragraph + + + `; + + const [_, { lastBlock }] = host.command.exec(getLastBlockCommand, { + role: 'hub', + root: undefined, + }); + + expect(lastBlock?.id).toBe('note-2'); + }); + + it('should return last block with any role in the array when found', () => { + const host = affine` + + + First Paragraph + Second Paragraph + + + First Paragraph + Second Paragraph + + + `; + + const [_, { lastBlock }] = host.command.exec(getLastBlockCommand, { + role: ['hub', 'content'], + root: undefined, + }); + + expect(lastBlock?.id).toBe('note-2'); + }); + + it('should return last block with specified flavour when found', () => { + const host = affine` + + + Paragraph + List Item + + + `; + + const note = host.doc.getBlock('note-1')?.model; + + const [_, { lastBlock }] = host.command.exec(getLastBlockCommand, { + flavour: 'affine:list', + root: note, + }); + + expect(lastBlock?.id).toBe('list-1'); + }); + + it('should return last block with any flavour in the array when found', () => { + const host = affine` + + + Paragraph + List Item + + + `; + + const note = host.doc.getBlock('note-1')?.model; + + const [_, { lastBlock }] = host.command.exec(getLastBlockCommand, { + flavour: ['affine:list', 'affine:code'], + root: note, + }); + + expect(lastBlock?.id).toBe('list-1'); + }); + + it('should return last block matching both role and flavour when both specified', () => { + const host = affine` + + + Content Paragraph + Content List + hub Paragraph + + + `; + + const note = host.doc.getBlock('note-1')?.model; + const [_, { lastBlock }] = host.command.exec(getLastBlockCommand, { + role: 'content', + flavour: 'affine:list', + root: note, + }); + + expect(lastBlock?.id).toBe('list-1'); + }); + + it('should return last block with default roles when role not specified', () => { + const host = affine` + + + hub Paragraph + Content Paragraph + Hub Paragraph + + + `; + + const [_, { lastBlock }] = host.command.exec(getLastBlockCommand, { + root: undefined, + }); + + expect(lastBlock?.id).toBe('note-1'); + }); + + it('should return last block with specified role when found', () => { + const host = affine` + + + Content Paragraph + hub Paragraph + Database + + + `; + + const note = host.doc.getBlock('note-1')?.model; + + const [_, { lastBlock }] = host.command.exec(getLastBlockCommand, { + role: 'hub', + root: note, + }); + + expect(lastBlock?.id).toBe('database-1'); + }); + + it('should return null when no blocks with specified role are found in children', () => { + const host = affine` + + + Content Paragraph + Another Content Paragraph + + + `; + + const note = host.doc.getBlock('note-1')?.model; + + const [_, { lastBlock }] = host.command.exec(getLastBlockCommand, { + role: 'hub', + root: note, + }); + + expect(lastBlock).toBeNull(); + }); + + it('should return null when no blocks with specified flavour are found in children', () => { + const host = affine` + + + Paragraph + Another Paragraph + + + `; + + const note = host.doc.getBlock('note-1')?.model; + + const [_, { lastBlock }] = host.command.exec(getLastBlockCommand, { + flavour: 'affine:list', + root: note, + }); + + expect(lastBlock).toBeNull(); + }); + + it('should return last block with specified role within specified root subtree', () => { + const host = affine` + + + 1-1 Content + 1-2 hub + + + 2-1 hub + 2-2 Content + + + `; + + const note = host.doc.getBlock('note-2')?.model; + + const [_, { lastBlock }] = host.command.exec(getLastBlockCommand, { + role: 'content', + root: note, + }); + + expect(lastBlock?.id).toBe('paragraph-2-2'); + }); + }); +}); diff --git a/blocksuite/affine/shared/src/__tests__/commands/block-crud/get-last-content-block.unit.spec.ts b/blocksuite/affine/shared/src/__tests__/commands/block-crud/get-last-content-block.unit.spec.ts deleted file mode 100644 index 8b1487b8c2..0000000000 --- a/blocksuite/affine/shared/src/__tests__/commands/block-crud/get-last-content-block.unit.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * @vitest-environment happy-dom - */ -import { describe, expect, it } from 'vitest'; - -import { getLastContentBlockCommand } from '../../../commands/block-crud/get-last-content-block'; -import { affine } from '../../helpers/affine-template'; - -describe('commands/block-crud', () => { - describe('getLastContentBlockCommand', () => { - it('should return null when root is not provided and no note block exists', () => { - const host = affine``; - - const [_, { lastBlock }] = host.command.exec(getLastContentBlockCommand, { - root: undefined, - std: { - host, - } as any, - }); - - expect(lastBlock).toBeNull(); - }); - - it('should return last content block when found', () => { - const host = affine` - - - First Paragraph - Second Paragraph - - - `; - - const [_, { lastBlock }] = host.command.exec(getLastContentBlockCommand, { - root: undefined, - }); - - expect(lastBlock?.id).toBe('paragraph-2'); - }); - - it('should return null when no content blocks are found in children', () => { - const host = affine` - - - - - `; - - const [_, { lastBlock }] = host.command.exec( - getLastContentBlockCommand, - {} - ); - - expect(lastBlock).toBeNull(); - }); - - it('should return last content block within specified root subtree', () => { - const host = affine` - - - 1-1 Paragraph - 1-2 Paragraph - - - 2-1 Paragraph - 2-2 Paragraph - - - `; - - const noteBlock = host.doc.getBlock('note-2')?.model; - - const [_, { lastBlock }] = host.command.exec(getLastContentBlockCommand, { - root: noteBlock, - }); - - expect(lastBlock?.id).toBe('paragraph-2-2'); - }); - }); -}); diff --git a/blocksuite/affine/shared/src/__tests__/helpers/affine-template.ts b/blocksuite/affine/shared/src/__tests__/helpers/affine-template.ts index 93615dd4fe..eca3305cb0 100644 --- a/blocksuite/affine/shared/src/__tests__/helpers/affine-template.ts +++ b/blocksuite/affine/shared/src/__tests__/helpers/affine-template.ts @@ -1,4 +1,5 @@ import { + DatabaseBlockSchemaExtension, ImageBlockSchemaExtension, ListBlockSchemaExtension, NoteBlockSchemaExtension, @@ -18,6 +19,7 @@ const extensions = [ ParagraphBlockSchemaExtension, ListBlockSchemaExtension, ImageBlockSchemaExtension, + DatabaseBlockSchemaExtension, ]; // Mapping from tag names to flavours @@ -27,6 +29,7 @@ const tagToFlavour: Record = { 'affine-paragraph': 'affine:paragraph', 'affine-list': 'affine:list', 'affine-image': 'affine:image', + 'affine-database': 'affine:database', }; /** diff --git a/blocksuite/affine/shared/src/__tests__/helpers/create-test-doc.ts b/blocksuite/affine/shared/src/__tests__/helpers/create-test-doc.ts deleted file mode 100644 index a5c5a9c4cc..0000000000 --- a/blocksuite/affine/shared/src/__tests__/helpers/create-test-doc.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { - BlockSchemaExtension, - defineBlockSchema, - type Store, - Text, -} from '@blocksuite/store'; -import { TestWorkspace } from '@blocksuite/store/test'; -import { type Element as HappyDOMElement, Window } from 'happy-dom'; - -// Define schema -const PageBlockSchema = defineBlockSchema({ - flavour: 'affine:page', - props: () => ({}), - metadata: { - version: 1, - role: 'root', - children: ['affine:note'], - }, -}); - -const NoteBlockSchema = defineBlockSchema({ - flavour: 'affine:note', - props: () => ({}), - metadata: { - version: 1, - role: 'hub', - parent: ['affine:page'], - children: ['affine:paragraph'], - }, -}); - -const ParagraphBlockSchema = defineBlockSchema({ - flavour: 'affine:paragraph', - props: internal => ({ - text: internal.Text(), - }), - metadata: { - version: 1, - role: 'content', - parent: ['affine:note'], - }, -}); - -// Create schema extensions -const PageBlockSchemaExtension = BlockSchemaExtension(PageBlockSchema); -const NoteBlockSchemaExtension = BlockSchemaExtension(NoteBlockSchema); -const ParagraphBlockSchemaExtension = - BlockSchemaExtension(ParagraphBlockSchema); - -// Extensions array -const extensions = [ - PageBlockSchemaExtension, - NoteBlockSchemaExtension, - ParagraphBlockSchemaExtension, -]; - -/** - * Parse HTML string and create document block structure - * @param node Current DOM node - * @param doc Document object - * @param parentId Parent block ID - * @returns Created block ID - */ -function processNode( - node: HappyDOMElement, - doc: Store, - parentId?: string -): string | undefined { - // Skip text nodes and comments - if (node.nodeType !== 1) { - return undefined; - } - - const tagName = node.tagName.toLowerCase(); - let blockId: string | undefined = undefined; - - // Create appropriate block based on tag name - if (tagName === 'affine-page') { - blockId = doc.addBlock('affine:page', {}, parentId); - } else if (tagName === 'affine-note') { - blockId = doc.addBlock('affine:note', {}, parentId); - } else if (tagName === 'affine-paragraph') { - // Get paragraph text content - const textContent = node.textContent || ''; - // Get attributes - const props: Record = { text: new Text(textContent) }; - - // Process custom attributes - for (const attr of Array.from(node.attributes)) { - if (attr.name === 'type') { - props.type = attr.value; - } else if (attr.name === 'checked' && attr.value === 'true') { - props.checked = true; - } - } - - blockId = doc.addBlock('affine:paragraph', props, parentId); - } else { - console.warn(`Unknown tag name: ${tagName}`); - return undefined; - } - - // Process child nodes - for (const childNode of Array.from(node.children) as HappyDOMElement[]) { - processNode(childNode, doc, blockId); - } - - return blockId; -} - -/** - * Create document from HTML string - * @param template HTML template string - * @returns Created document object - */ -export function createDocFromHTML(template: string) { - const workspace = new TestWorkspace({}); - workspace.meta.initialize(); - - const doc = workspace.createDoc({ id: 'test-doc', extensions }); - - doc.load(() => { - const window = new Window(); - const document = window.document; - const container = document.createElement('div'); - container.innerHTML = template; - - // Process each child node of the root - for (const childNode of Array.from(container.children)) { - processNode(childNode, doc); - } - }); - - return doc; -} diff --git a/blocksuite/affine/shared/src/commands/block-crud/get-first-content-block.ts b/blocksuite/affine/shared/src/commands/block-crud/get-first-content-block.ts index 4cb139831d..4d586f0c41 100644 --- a/blocksuite/affine/shared/src/commands/block-crud/get-first-content-block.ts +++ b/blocksuite/affine/shared/src/commands/block-crud/get-first-content-block.ts @@ -1,35 +1,57 @@ import type { Command } from '@blocksuite/block-std'; import type { BlockModel } from '@blocksuite/store'; -import { getFirstNoteBlock } from '../../utils'; +type Role = 'content' | 'hub'; /** - * Get the first content block in the document + * Get the first block with specified roles and flavours in the document * * @param ctx - Command context * @param ctx.root - The root note block model + * @param ctx.role - The roles to match, can be string or string array. If not provided, default to all supported roles. + * @param ctx.flavour - The flavours to match, can be string or string array. If not provided, match any flavour. * @param next - Next handler function - * @returns The first content block model or null + * @returns The first block model matched or null */ -export const getFirstContentBlockCommand: Command< +export const getFirstBlockCommand: Command< { root?: BlockModel; + role?: Role | Role[]; + flavour?: string | string[]; }, { firstBlock: BlockModel | null; } > = (ctx, next) => { - const doc = ctx.std.host.doc; - const noteBlock = ctx.root ?? getFirstNoteBlock(doc); - if (!noteBlock) { + const root = ctx.root || ctx.std.host.doc.root; + if (!root) { next({ firstBlock: null, }); return; } - for (const child of noteBlock.children) { - if (child.role === 'content') { + const defaultRoles = ['content', 'hub']; + + const rolesToMatch = ctx.role + ? Array.isArray(ctx.role) + ? ctx.role + : [ctx.role] + : defaultRoles; + + const flavoursToMatch = ctx.flavour + ? Array.isArray(ctx.flavour) + ? ctx.flavour + : [ctx.flavour] + : null; + + for (const child of root.children) { + const roleMatches = rolesToMatch.includes(child.role); + + const flavourMatches = + !flavoursToMatch || flavoursToMatch.includes(child.flavour); + + if (roleMatches && flavourMatches) { next({ firstBlock: child, }); diff --git a/blocksuite/affine/shared/src/commands/block-crud/get-last-content-block.ts b/blocksuite/affine/shared/src/commands/block-crud/get-last-content-block.ts index 9612a6201a..ae6eb89a64 100644 --- a/blocksuite/affine/shared/src/commands/block-crud/get-last-content-block.ts +++ b/blocksuite/affine/shared/src/commands/block-crud/get-last-content-block.ts @@ -1,35 +1,59 @@ import type { Command } from '@blocksuite/block-std'; import type { BlockModel } from '@blocksuite/store'; -import { getLastNoteBlock } from '../../utils'; +type Role = 'content' | 'hub'; /** - * Get the last content block in the document + * Get the last block with specified roles and flavours in the document * * @param ctx - Command context * @param ctx.root - The root note block model + * @param ctx.role - The roles to match, can be string or string array. If not provided, default to all supported roles. + * @param ctx.flavour - The flavours to match, can be string or string array. If not provided, match any flavour. * @param next - Next handler function - * @returns The last content block model or null + * @returns The last block model matched or null */ -export const getLastContentBlockCommand: Command< +export const getLastBlockCommand: Command< { root?: BlockModel; + role?: Role | Role[]; + flavour?: string | string[]; }, { lastBlock: BlockModel | null; } > = (ctx, next) => { - const noteBlock = ctx.root ?? getLastNoteBlock(ctx.std.host.doc); - if (!noteBlock) { + const root = ctx.root || ctx.std.host.doc.root; + if (!root) { next({ lastBlock: null, }); return; } - const children = noteBlock.children; + const defaultRoles = ['content', 'hub']; + + const rolesToMatch = ctx.role + ? Array.isArray(ctx.role) + ? ctx.role + : [ctx.role] + : defaultRoles; + + const flavoursToMatch = ctx.flavour + ? Array.isArray(ctx.flavour) + ? ctx.flavour + : [ctx.flavour] + : null; + + const children = root.children; for (let i = children.length - 1; i >= 0; i--) { - if (children[i].role === 'content') { + const roleMatches = rolesToMatch.includes(children[i].role); + + const flavourMatches = + !flavoursToMatch || flavoursToMatch.includes(children[i].flavour); + + // Both role and flavour must match + if (roleMatches && flavourMatches) { next({ lastBlock: children[i], }); diff --git a/blocksuite/affine/shared/src/commands/block-crud/index.ts b/blocksuite/affine/shared/src/commands/block-crud/index.ts index 8e1d26d0f2..10177ef2d5 100644 --- a/blocksuite/affine/shared/src/commands/block-crud/index.ts +++ b/blocksuite/affine/shared/src/commands/block-crud/index.ts @@ -1,6 +1,6 @@ export { getBlockIndexCommand } from './get-block-index.js'; -export { getFirstContentBlockCommand } from './get-first-content-block.js'; -export { getLastContentBlockCommand } from './get-last-content-block.js'; +export { getFirstBlockCommand } from './get-first-content-block.js'; +export { getLastBlockCommand } from './get-last-content-block.js'; export { getNextBlockCommand } from './get-next-block.js'; export { getPrevBlockCommand } from './get-prev-block.js'; export { getSelectedBlocksCommand } from './get-selected-blocks.js'; diff --git a/blocksuite/affine/shared/src/commands/index.ts b/blocksuite/affine/shared/src/commands/index.ts index b2d8eb6f5d..00cd284aba 100644 --- a/blocksuite/affine/shared/src/commands/index.ts +++ b/blocksuite/affine/shared/src/commands/index.ts @@ -1,7 +1,7 @@ export { getBlockIndexCommand, - getFirstContentBlockCommand, - getLastContentBlockCommand, + getFirstBlockCommand, + getLastBlockCommand, getNextBlockCommand, getPrevBlockCommand, getSelectedBlocksCommand,