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
new file mode 100644
index 000000000..34652502b
--- /dev/null
+++ b/blocksuite/affine/shared/src/__tests__/commands/block-crud/get-first-content-block.unit.spec.ts
@@ -0,0 +1,89 @@
+/**
+ * @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-content-block.unit.spec.ts b/blocksuite/affine/shared/src/__tests__/commands/block-crud/get-last-content-block.unit.spec.ts
new file mode 100644
index 000000000..8b1487b8c
--- /dev/null
+++ b/blocksuite/affine/shared/src/__tests__/commands/block-crud/get-last-content-block.unit.spec.ts
@@ -0,0 +1,80 @@
+/**
+ * @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__/commands/selection/is-nothing-selected.unit.spec.ts b/blocksuite/affine/shared/src/__tests__/commands/selection/is-nothing-selected.unit.spec.ts
new file mode 100644
index 000000000..550adfd57
--- /dev/null
+++ b/blocksuite/affine/shared/src/__tests__/commands/selection/is-nothing-selected.unit.spec.ts
@@ -0,0 +1,147 @@
+/**
+ * @vitest-environment happy-dom
+ */
+import { BlockSelection, TextSelection } from '@blocksuite/block-std';
+import { describe, expect, it, vi } from 'vitest';
+
+import { isNothingSelectedCommand } from '../../../commands/selection/is-nothing-selected';
+import { ImageSelection } from '../../../selection';
+import { affine } from '../../helpers/affine-template';
+
+describe('commands/selection', () => {
+ describe('isNothingSelectedCommand', () => {
+ it('should return true when nothing is selected', () => {
+ const host = affine``;
+
+ const [_, { isNothingSelected }] = host.command.exec(
+ isNothingSelectedCommand,
+ {}
+ );
+
+ expect(isNothingSelected).toBe(true);
+ });
+
+ it('should return false when text selection exists', () => {
+ const host = affine`
+
+
+ Test paragraph
+
+
+ `;
+
+ // Mock text selection
+ const textSelection = new TextSelection({
+ from: {
+ blockId: 'paragraph-1',
+ index: 0,
+ length: 0,
+ },
+ to: {
+ blockId: 'paragraph-1',
+ index: 4,
+ length: 0,
+ },
+ });
+
+ const [_, { isNothingSelected }] = host.command.exec(
+ isNothingSelectedCommand,
+ {
+ currentTextSelection: textSelection,
+ }
+ );
+
+ expect(isNothingSelected).toBe(false);
+ });
+
+ it('should return false when block selection exists', () => {
+ const host = affine`
+
+
+ Test paragraph
+
+
+ `;
+
+ // Mock block selection
+ const blockSelection = new BlockSelection({
+ blockId: 'paragraph-1',
+ });
+
+ const [_, { isNothingSelected }] = host.command.exec(
+ isNothingSelectedCommand,
+ {
+ currentBlockSelections: [blockSelection],
+ }
+ );
+
+ expect(isNothingSelected).toBe(false);
+ });
+
+ it('should return false when image selection exists', () => {
+ const host = affine`
+
+
+ Test paragraph
+
+
+ `;
+
+ // Mock image selection
+ const imageSelection = new ImageSelection({
+ blockId: 'image-1',
+ });
+
+ const [_, { isNothingSelected }] = host.command.exec(
+ isNothingSelectedCommand,
+ {
+ currentImageSelections: [imageSelection],
+ }
+ );
+
+ expect(isNothingSelected).toBe(false);
+ });
+
+ it('should return false when no selection is provided but selection is found in context', () => {
+ const host = affine`
+
+
+ Test paragraph
+
+
+ `;
+
+ // Mock selection behavior via vi.spyOn before executing the command
+ const mockTextSelection = new TextSelection({
+ from: {
+ blockId: 'paragraph-1',
+ index: 0,
+ length: 0,
+ },
+ to: null,
+ });
+
+ const mockContext = {
+ // No explicit `currentTextSelection provided
+ std: {
+ selection: {
+ find: vi.fn().mockImplementation(type => {
+ if (type === TextSelection) {
+ return mockTextSelection;
+ }
+ return null;
+ }),
+ filter: vi.fn().mockReturnValue([]),
+ },
+ },
+ };
+
+ const [_, { isNothingSelected }] = host.command.exec(
+ isNothingSelectedCommand,
+ mockContext as any
+ );
+
+ expect(isNothingSelected).toBe(false);
+ });
+ });
+});
diff --git a/blocksuite/affine/shared/src/__tests__/helpers/README.md b/blocksuite/affine/shared/src/__tests__/helpers/README.md
new file mode 100644
index 000000000..933058a0a
--- /dev/null
+++ b/blocksuite/affine/shared/src/__tests__/helpers/README.md
@@ -0,0 +1,79 @@
+# AFFiNE Test Tools
+
+## Structured Document Creation
+
+`affine-template.ts` provides a concise way to create test documents, using a html-like syntax.
+
+### Basic Usage
+
+```typescript
+import { affine } from '../__tests__/utils/affine-template';
+
+// Create a simple document
+const doc = affine`
+
+
+ Hello, World!
+
+
+`;
+```
+
+### Complex Structure Example
+
+```typescript
+// Create a document with multiple notes and paragraphs
+const doc = affine`
+
+
+ First paragraph
+ Second paragraph
+
+
+ Another note
+
+
+`;
+```
+
+### Application in Tests
+
+This tool is particularly suitable for creating documents with specific structures in test cases:
+
+```typescript
+import { describe, expect, it } from 'vitest';
+import { affine } from '../__tests__/utils/affine-template';
+
+describe('My Test', () => {
+ it('should correctly handle document structure', () => {
+ const doc = affine`
+
+
+ Test content
+
+
+ `;
+
+ // Get blocks
+ const pages = doc.getBlocksByFlavour('affine:page');
+ const notes = doc.getBlocksByFlavour('affine:note');
+ const paragraphs = doc.getBlocksByFlavour('affine:paragraph');
+
+ expect(pages.length).toBe(1);
+ expect(notes.length).toBe(1);
+ expect(paragraphs.length).toBe(1);
+
+ // Perform more tests here...
+ });
+});
+```
+
+### Supported Block Types
+
+Currently supports the following block types:
+
+- `affine-page` → `affine:page`
+- `affine-note` → `affine:note`
+- `affine-paragraph` → `affine:paragraph`
+- `affine-list` → `affine:list`
+- `affine-image` → `affine:image`
diff --git a/blocksuite/affine/shared/src/__tests__/helpers/affine-template.ts b/blocksuite/affine/shared/src/__tests__/helpers/affine-template.ts
new file mode 100644
index 000000000..93615dd4f
--- /dev/null
+++ b/blocksuite/affine/shared/src/__tests__/helpers/affine-template.ts
@@ -0,0 +1,179 @@
+import {
+ ImageBlockSchemaExtension,
+ ListBlockSchemaExtension,
+ NoteBlockSchemaExtension,
+ ParagraphBlockSchemaExtension,
+ RootBlockSchemaExtension,
+} from '@blocksuite/affine-model';
+import { type Block, type Store } from '@blocksuite/store';
+import { Text } from '@blocksuite/store';
+import { TestWorkspace } from '@blocksuite/store/test';
+
+import { createTestHost } from './create-test-host';
+
+// Extensions array
+const extensions = [
+ RootBlockSchemaExtension,
+ NoteBlockSchemaExtension,
+ ParagraphBlockSchemaExtension,
+ ListBlockSchemaExtension,
+ ImageBlockSchemaExtension,
+];
+
+// Mapping from tag names to flavours
+const tagToFlavour: Record = {
+ 'affine-page': 'affine:page',
+ 'affine-note': 'affine:note',
+ 'affine-paragraph': 'affine:paragraph',
+ 'affine-list': 'affine:list',
+ 'affine-image': 'affine:image',
+};
+
+/**
+ * Parse template strings and build BlockSuite document structure,
+ * then create a host object with the document
+ *
+ * Example:
+ * ```
+ * const host = affine`
+ *
+ *
+ * Hello, world
+ *
+ *
+ * `;
+ * ```
+ */
+export function affine(strings: TemplateStringsArray, ...values: any[]) {
+ // Merge template strings and values
+ let htmlString = '';
+ strings.forEach((str, i) => {
+ htmlString += str;
+ if (i < values.length) {
+ htmlString += values[i];
+ }
+ });
+
+ // Create a new doc
+ const workspace = new TestWorkspace({});
+ workspace.meta.initialize();
+ const doc = workspace.createDoc({ id: 'test-doc', extensions });
+
+ // Use DOMParser to parse HTML string
+ doc.load(() => {
+ const parser = new DOMParser();
+ const dom = parser.parseFromString(htmlString.trim(), 'text/html');
+ const root = dom.body.firstElementChild;
+
+ if (!root) {
+ throw new Error('Template must contain a root element');
+ }
+
+ buildDocFromElement(doc, root, null);
+ });
+
+ // Create and return a host object with the document
+ return createTestHost(doc);
+}
+
+/**
+ * Create a single block from template string
+ *
+ * Example:
+ * ```
+ * const block = block``
+ * ```
+ */
+export function block(
+ strings: TemplateStringsArray,
+ ...values: any[]
+): Block | null {
+ // Merge template strings and values
+ let htmlString = '';
+ strings.forEach((str, i) => {
+ htmlString += str;
+ if (i < values.length) {
+ htmlString += values[i];
+ }
+ });
+
+ // Create a temporary doc to hold the block
+ const workspace = new TestWorkspace({});
+ workspace.meta.initialize();
+ const doc = workspace.createDoc({ id: 'temp-doc', extensions });
+
+ let blockId: string | null = null;
+
+ // Use DOMParser to parse HTML string
+ doc.load(() => {
+ const parser = new DOMParser();
+ const dom = parser.parseFromString(htmlString.trim(), 'text/html');
+ const root = dom.body.firstElementChild;
+
+ if (!root) {
+ throw new Error('Template must contain a root element');
+ }
+
+ blockId = buildDocFromElement(doc, root, null);
+ });
+
+ // Return the created block
+ return blockId ? (doc.getBlock(blockId) ?? null) : null;
+}
+
+/**
+ * Recursively build document structure
+ * @param doc
+ * @param element
+ * @param parentId
+ * @returns
+ */
+function buildDocFromElement(
+ doc: Store,
+ element: Element,
+ parentId: string | null
+): string {
+ const tagName = element.tagName.toLowerCase();
+ const flavour = tagToFlavour[tagName];
+
+ if (!flavour) {
+ throw new Error(`Unknown tag name: ${tagName}`);
+ }
+
+ const props: Record = {};
+
+ const customId = element.getAttribute('id');
+
+ // If ID is specified, add it to props
+ if (customId) {
+ props.id = customId;
+ }
+
+ // Process element attributes
+ Array.from(element.attributes).forEach(attr => {
+ if (attr.name !== 'id') {
+ // Skip id attribute, we already handled it
+ props[attr.name] = attr.value;
+ }
+ });
+
+ // Special handling for different block types based on their flavours
+ switch (flavour) {
+ case 'affine:paragraph':
+ case 'affine:list':
+ if (element.textContent) {
+ props.text = new Text(element.textContent);
+ }
+ break;
+ }
+
+ // Create block
+ const blockId = doc.addBlock(flavour, props, parentId);
+
+ // Recursively process child elements
+ Array.from(element.children).forEach(child => {
+ buildDocFromElement(doc, child, blockId);
+ });
+
+ return blockId;
+}
diff --git a/blocksuite/affine/shared/src/__tests__/helpers/affine-template.unit.spec.ts b/blocksuite/affine/shared/src/__tests__/helpers/affine-template.unit.spec.ts
new file mode 100644
index 000000000..389fe9c68
--- /dev/null
+++ b/blocksuite/affine/shared/src/__tests__/helpers/affine-template.unit.spec.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from 'vitest';
+
+import { affine } from './affine-template';
+
+describe('helpers/affine-template', () => {
+ it('should create a basic document structure from template', () => {
+ const host = affine`
+
+
+ Hello, world
+
+
+ `;
+
+ expect(host.doc).toBeDefined();
+
+ const pageBlock = host.doc.getBlock('page');
+ expect(pageBlock).toBeDefined();
+ expect(pageBlock?.flavour).toBe('affine:page');
+
+ const noteBlock = host.doc.getBlock('note');
+ expect(noteBlock).toBeDefined();
+ expect(noteBlock?.flavour).toBe('affine:note');
+
+ const paragraphBlock = host.doc.getBlock('paragraph-1');
+ expect(paragraphBlock).toBeDefined();
+ expect(paragraphBlock?.flavour).toBe('affine:paragraph');
+ });
+
+ it('should handle nested blocks correctly', () => {
+ const host = affine`
+
+
+ First paragraph
+ List item
+ Second paragraph
+
+
+ `;
+
+ const noteBlocks = host.doc.getBlocksByFlavour('affine:note');
+ const paragraphBlocks = host.doc.getBlocksByFlavour('affine:paragraph');
+ const listBlocks = host.doc.getBlocksByFlavour('affine:list');
+
+ expect(noteBlocks.length).toBe(1);
+ expect(paragraphBlocks.length).toBe(2);
+ expect(listBlocks.length).toBe(1);
+
+ const noteBlock = noteBlocks[0];
+ const noteChildren = host.doc.getBlock(noteBlock.id)?.model.children || [];
+ expect(noteChildren.length).toBe(3);
+
+ expect(noteChildren[0].flavour).toBe('affine:paragraph');
+ expect(noteChildren[1].flavour).toBe('affine:list');
+ expect(noteChildren[2].flavour).toBe('affine:paragraph');
+ });
+
+ it('should handle empty blocks correctly', () => {
+ const host = affine`
+
+
+
+
+
+ `;
+
+ const paragraphBlocks = host.doc.getBlocksByFlavour('affine:paragraph');
+ expect(paragraphBlocks.length).toBe(1);
+
+ const paragraphBlock = host.doc.getBlock(paragraphBlocks[0].id);
+ const paragraphText = paragraphBlock?.model.text?.toString() || '';
+ expect(paragraphText).toBe('');
+ });
+
+ it('should throw error on invalid template', () => {
+ expect(() => {
+ affine`
+
+ `;
+ }).toThrow();
+ });
+});
diff --git a/blocksuite/affine/shared/src/__tests__/helpers/create-test-doc.ts b/blocksuite/affine/shared/src/__tests__/helpers/create-test-doc.ts
new file mode 100644
index 000000000..a5c5a9c4c
--- /dev/null
+++ b/blocksuite/affine/shared/src/__tests__/helpers/create-test-doc.ts
@@ -0,0 +1,135 @@
+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/__tests__/helpers/create-test-host.ts b/blocksuite/affine/shared/src/__tests__/helpers/create-test-host.ts
new file mode 100644
index 000000000..bfd999e83
--- /dev/null
+++ b/blocksuite/affine/shared/src/__tests__/helpers/create-test-host.ts
@@ -0,0 +1,246 @@
+import { CommandManager, type EditorHost } from '@blocksuite/block-std';
+import type { Block, Store } from '@blocksuite/store';
+import { Subject } from 'rxjs';
+
+interface MockBlockComponent {
+ id: string;
+ model: Block;
+ flavour: string;
+ role: string;
+ parentElement: MockBlockComponent | null;
+ closest: (selector: string) => MockBlockComponent | null;
+ querySelector: (selector: string) => MockBlockComponent | null;
+ querySelectorAll: (selector: string) => MockBlockComponent[];
+ children: MockBlockComponent[];
+}
+
+type ViewUpdateMethod = 'delete' | 'add';
+type ViewUpdatePayload = {
+ id: string;
+ method: ViewUpdateMethod;
+ type: 'block';
+ view: MockBlockComponent;
+};
+
+/**
+ * Mock selection class for testing
+ */
+class MockSelectionStore {
+ private _selections: any[] = [];
+
+ constructor() {}
+
+ get value() {
+ return this._selections;
+ }
+
+ create(selectionClass: any, ...args: any[]) {
+ return new selectionClass(...args);
+ }
+
+ setGroup(group: string, selections: any[]) {
+ this._selections = this._selections.filter(s => s.group !== group);
+ this._selections.push(...selections);
+ return this;
+ }
+
+ set(selections: any[]) {
+ this._selections = selections;
+ return this;
+ }
+
+ find(type: any) {
+ return this._selections.find(s => s instanceof type);
+ }
+
+ filter(type: any) {
+ return this._selections.filter(s => s instanceof type);
+ }
+
+ clear() {
+ this._selections = [];
+ return this;
+ }
+
+ slots = {
+ changed: {
+ emit: () => {},
+ },
+ remoteChanged: {
+ emit: () => {},
+ },
+ };
+
+ dispose() {
+ this._selections = [];
+ }
+}
+
+class MockViewStore {
+ private readonly _blockMap = new Map();
+ viewUpdated = new Subject();
+
+ constructor(private readonly doc: Store) {}
+
+ get views() {
+ return Array.from(this._blockMap.values());
+ }
+
+ deleteBlock(node: MockBlockComponent) {
+ this._blockMap.delete(node.model.id);
+ this.viewUpdated.next({
+ id: node.model.id,
+ method: 'delete',
+ type: 'block',
+ view: node,
+ });
+ }
+
+ getBlock(id: string): MockBlockComponent | null {
+ if (this._blockMap.has(id)) {
+ return this._blockMap.get(id) || null;
+ }
+
+ const block = this.doc.getBlock(id);
+ if (!block) return null;
+
+ const mockComponent = this._createMockBlockComponent(block);
+ this._blockMap.set(id, mockComponent);
+
+ return mockComponent;
+ }
+
+ setBlock(node: MockBlockComponent) {
+ if (this._blockMap.has(node.model.id)) {
+ this.deleteBlock(node);
+ }
+ this._blockMap.set(node.model.id, node);
+ this.viewUpdated.next({
+ id: node.model.id,
+ method: 'add',
+ type: 'block',
+ view: node,
+ });
+ }
+
+ private _createMockBlockComponent(block: Block): MockBlockComponent {
+ const role = this._determineBlockRole(block);
+
+ const mockComponent: MockBlockComponent = {
+ id: block.id,
+ model: block,
+ flavour: block.flavour,
+ role,
+ parentElement: null,
+ children: [],
+ closest: () => null,
+ querySelector: () => null,
+ querySelectorAll: () => [],
+ };
+
+ this._setupParentChildRelationships(mockComponent);
+
+ return mockComponent;
+ }
+
+ private _determineBlockRole(block: Block): string {
+ if (
+ block.flavour.includes('paragraph') ||
+ block.flavour.includes('list') ||
+ block.flavour.includes('list-item') ||
+ block.flavour.includes('text')
+ ) {
+ return 'content';
+ }
+ return 'root';
+ }
+
+ private _setupParentChildRelationships(component: MockBlockComponent) {
+ const parentId = (component.model as any).parentId;
+ if (parentId) {
+ const parentComponent = this.getBlock(parentId);
+ if (parentComponent) {
+ component.parentElement = parentComponent;
+
+ if (
+ !parentComponent.children.find(child => child.id === component.id)
+ ) {
+ parentComponent.children.push(component);
+ }
+ }
+ }
+
+ try {
+ const childIds =
+ (component.model as any).children?.map((child: any) =>
+ typeof child === 'string' ? child : child.id
+ ) || [];
+
+ for (const childId of childIds) {
+ const childBlock = this.doc.getBlock(childId);
+ if (childBlock) {
+ const childComponent =
+ this.getBlock(childId) ||
+ this._createMockBlockComponent(childBlock);
+ if (
+ !component.children.find(child => child.id === childComponent.id)
+ ) {
+ component.children.push(childComponent);
+ childComponent.parentElement = component;
+ }
+ }
+ }
+ } catch {
+ // ignore
+ }
+ }
+
+ dispose() {
+ this._blockMap.clear();
+ }
+}
+
+/**
+ * Create a test host object
+ *
+ * This function creates a mock host object that includes doc and command properties,
+ * which can be used for testing command execution.
+ *
+ * Usage:
+ * ```typescript
+ * const doc = affine``;
+ * const host = createTestHost(doc);
+ *
+ * // Use host.command.exec to execute commands
+ * const [_, result] = host.command.exec(someCommand, {
+ * // command params
+ * });
+ * ```
+ *
+ * @param doc Document object
+ * @returns Host object containing doc and command
+ */
+export function createTestHost(doc: Store): EditorHost {
+ const std = {
+ host: undefined as any,
+ view: new MockViewStore(doc),
+ command: undefined as any,
+ selection: undefined as any,
+ };
+
+ const host = {
+ doc: doc,
+ std: std as any,
+ };
+ host.doc = doc;
+ host.std = std as any;
+
+ std.host = host;
+ std.selection = new MockSelectionStore();
+
+ std.command = new CommandManager(std as any);
+ // @ts-expect-error
+ host.command = std.command;
+
+ return host as EditorHost;
+}
diff --git a/blocksuite/affine/shared/src/commands/index.ts b/blocksuite/affine/shared/src/commands/index.ts
index 2ca07d21b..b2d8eb6f5 100644
--- a/blocksuite/affine/shared/src/commands/index.ts
+++ b/blocksuite/affine/shared/src/commands/index.ts
@@ -1,5 +1,7 @@
export {
getBlockIndexCommand,
+ getFirstContentBlockCommand,
+ getLastContentBlockCommand,
getNextBlockCommand,
getPrevBlockCommand,
getSelectedBlocksCommand,
@@ -21,5 +23,6 @@ export {
getRangeRects,
getSelectionRectsCommand,
getTextSelectionCommand,
+ isNothingSelectedCommand,
type SelectionRect,
} from './selection/index.js';