mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-02 01:49:51 +08:00
test(editor): getFirstContentBlock & getLastContentBlock & isNothingSelected command (#10757)
### TL;DR
Added unit tests for block and selection commands, along with a new test helper system for creating test documents.
### What changed?
- Added unit tests for several commands:
- `getFirstContentBlockCommand`
- `getLastContentBlockCommand`
- `isNothingSelectedCommand`
- Created a new test helpers make it easier to create structured test documents with a html-like syntax:
```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`
<affine-page>
<affine-note>
<affine-paragraph>Test content</affine-paragraph>
</affine-note>
</affine-page>
`;
// 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...
});
});
```
This commit is contained in:
+89
@@ -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`<affine-page></affine-page>`;
|
||||||
|
|
||||||
|
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`
|
||||||
|
<affine-page>
|
||||||
|
<affine-note id="note-1">
|
||||||
|
<affine-paragraph id="paragraph-1">First Paragraph</affine-paragraph>
|
||||||
|
<affine-paragraph id="paragraph-2">Second Paragraph</affine-paragraph>
|
||||||
|
</affine-note>
|
||||||
|
</affine-page>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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`
|
||||||
|
<affine-page>
|
||||||
|
<affine-note id="note-1">
|
||||||
|
</affine-note>
|
||||||
|
</affine-page>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [_, { firstBlock }] = host.command.exec(
|
||||||
|
getFirstContentBlockCommand,
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(firstBlock).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return first content block within specified root subtree', () => {
|
||||||
|
const host = affine`
|
||||||
|
<affine-page>
|
||||||
|
<affine-note id="note-1">
|
||||||
|
<affine-paragraph id="paragraph-1-1">1-1 Paragraph</affine-paragraph>
|
||||||
|
<affine-paragraph id="paragraph-1-2">1-2 Paragraph</affine-paragraph>
|
||||||
|
</affine-note>
|
||||||
|
<affine-note id="note-2">
|
||||||
|
<affine-paragraph id="paragraph-2-1">2-1 Paragraph</affine-paragraph>
|
||||||
|
<affine-paragraph id="paragraph-2-2">2-2 Paragraph</affine-paragraph>
|
||||||
|
</affine-note>
|
||||||
|
</affine-page>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const noteBlock = host.doc.getBlock('note-2')?.model;
|
||||||
|
|
||||||
|
const [_, { firstBlock }] = host.command.exec(
|
||||||
|
getFirstContentBlockCommand,
|
||||||
|
{
|
||||||
|
root: noteBlock,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(firstBlock?.id).toBe('paragraph-2-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+80
@@ -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`<affine-page></affine-page>`;
|
||||||
|
|
||||||
|
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`
|
||||||
|
<affine-page>
|
||||||
|
<affine-note id="note-1">
|
||||||
|
<affine-paragraph id="paragraph-1">First Paragraph</affine-paragraph>
|
||||||
|
<affine-paragraph id="paragraph-2">Second Paragraph</affine-paragraph>
|
||||||
|
</affine-note>
|
||||||
|
</affine-page>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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`
|
||||||
|
<affine-page>
|
||||||
|
<affine-note id="note-1">
|
||||||
|
</affine-note>
|
||||||
|
</affine-page>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [_, { lastBlock }] = host.command.exec(
|
||||||
|
getLastContentBlockCommand,
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(lastBlock).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return last content block within specified root subtree', () => {
|
||||||
|
const host = affine`
|
||||||
|
<affine-page>
|
||||||
|
<affine-note id="note-1">
|
||||||
|
<affine-paragraph id="paragraph-1-1">1-1 Paragraph</affine-paragraph>
|
||||||
|
<affine-paragraph id="paragraph-1-2">1-2 Paragraph</affine-paragraph>
|
||||||
|
</affine-note>
|
||||||
|
<affine-note id="note-2">
|
||||||
|
<affine-paragraph id="paragraph-2-1">2-1 Paragraph</affine-paragraph>
|
||||||
|
<affine-paragraph id="paragraph-2-2">2-2 Paragraph</affine-paragraph>
|
||||||
|
</affine-note>
|
||||||
|
</affine-page>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const noteBlock = host.doc.getBlock('note-2')?.model;
|
||||||
|
|
||||||
|
const [_, { lastBlock }] = host.command.exec(getLastContentBlockCommand, {
|
||||||
|
root: noteBlock,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lastBlock?.id).toBe('paragraph-2-2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+147
@@ -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`<affine-page></affine-page>`;
|
||||||
|
|
||||||
|
const [_, { isNothingSelected }] = host.command.exec(
|
||||||
|
isNothingSelectedCommand,
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(isNothingSelected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when text selection exists', () => {
|
||||||
|
const host = affine`
|
||||||
|
<affine-page>
|
||||||
|
<affine-note id="note-1">
|
||||||
|
<affine-paragraph id="paragraph-1">Test paragraph</affine-paragraph>
|
||||||
|
</affine-note>
|
||||||
|
</affine-page>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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`
|
||||||
|
<affine-page>
|
||||||
|
<affine-note id="note-1">
|
||||||
|
<affine-paragraph id="paragraph-1">Test paragraph</affine-paragraph>
|
||||||
|
</affine-note>
|
||||||
|
</affine-page>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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`
|
||||||
|
<affine-page>
|
||||||
|
<affine-note id="note-1">
|
||||||
|
<affine-image id="image-1">Test paragraph</affine-image>
|
||||||
|
</affine-note>
|
||||||
|
</affine-page>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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`
|
||||||
|
<affine-page>
|
||||||
|
<affine-note id="note-1">
|
||||||
|
<affine-paragraph id="paragraph-1">Test paragraph</affine-paragraph>
|
||||||
|
</affine-note>
|
||||||
|
</affine-page>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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`
|
||||||
|
<affine-page>
|
||||||
|
<affine-note>
|
||||||
|
<affine-paragraph>Hello, World!</affine-paragraph>
|
||||||
|
</affine-note>
|
||||||
|
</affine-page>
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Complex Structure Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Create a document with multiple notes and paragraphs
|
||||||
|
const doc = affine`
|
||||||
|
<affine-page title="My Test Page">
|
||||||
|
<affine-note>
|
||||||
|
<affine-paragraph>First paragraph</affine-paragraph>
|
||||||
|
<affine-paragraph>Second paragraph</affine-paragraph>
|
||||||
|
</affine-note>
|
||||||
|
<affine-note>
|
||||||
|
<affine-paragraph>Another note</affine-paragraph>
|
||||||
|
</affine-note>
|
||||||
|
</affine-page>
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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`
|
||||||
|
<affine-page>
|
||||||
|
<affine-note>
|
||||||
|
<affine-paragraph>Test content</affine-paragraph>
|
||||||
|
</affine-note>
|
||||||
|
</affine-page>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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`
|
||||||
@@ -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<string, string> = {
|
||||||
|
'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`
|
||||||
|
* <affine-page id="page">
|
||||||
|
* <affine-note id="note">
|
||||||
|
* <affine-paragraph id="paragraph-1">Hello, world</affine-paragraph>
|
||||||
|
* </affine-note>
|
||||||
|
* </affine-page>
|
||||||
|
* `;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
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`<affine-note />`
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
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<string, any> = {};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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`
|
||||||
|
<affine-page id="page">
|
||||||
|
<affine-note id="note">
|
||||||
|
<affine-paragraph id="paragraph-1">Hello, world</affine-paragraph>
|
||||||
|
</affine-note>
|
||||||
|
</affine-page>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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`
|
||||||
|
<affine-page>
|
||||||
|
<affine-note>
|
||||||
|
<affine-paragraph>First paragraph</affine-paragraph>
|
||||||
|
<affine-list>List item</affine-list>
|
||||||
|
<affine-paragraph>Second paragraph</affine-paragraph>
|
||||||
|
</affine-note>
|
||||||
|
</affine-page>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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`
|
||||||
|
<affine-page>
|
||||||
|
<affine-note>
|
||||||
|
<affine-paragraph></affine-paragraph>
|
||||||
|
</affine-note>
|
||||||
|
</affine-page>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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`
|
||||||
|
<unknown-tag></unknown-tag>
|
||||||
|
`;
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, any> = { 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;
|
||||||
|
}
|
||||||
@@ -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<string, MockBlockComponent>();
|
||||||
|
viewUpdated = new Subject<ViewUpdatePayload>();
|
||||||
|
|
||||||
|
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`<affine-page></affine-page>`;
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
export {
|
export {
|
||||||
getBlockIndexCommand,
|
getBlockIndexCommand,
|
||||||
|
getFirstContentBlockCommand,
|
||||||
|
getLastContentBlockCommand,
|
||||||
getNextBlockCommand,
|
getNextBlockCommand,
|
||||||
getPrevBlockCommand,
|
getPrevBlockCommand,
|
||||||
getSelectedBlocksCommand,
|
getSelectedBlocksCommand,
|
||||||
@@ -21,5 +23,6 @@ export {
|
|||||||
getRangeRects,
|
getRangeRects,
|
||||||
getSelectionRectsCommand,
|
getSelectionRectsCommand,
|
||||||
getTextSelectionCommand,
|
getTextSelectionCommand,
|
||||||
|
isNothingSelectedCommand,
|
||||||
type SelectionRect,
|
type SelectionRect,
|
||||||
} from './selection/index.js';
|
} from './selection/index.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user