mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-08 04:36:13 +08:00
fix(core): ai replace selection (#11875)
### TL;DR * Fix the issue of inaccurate content replacement in AI Replace Selection * Optimize unit Tests utils ### What Changed 1. Fixed the issue of inaccurate content replacement in AI Replace Selection: - Convert the AI Answer into a Snapshot, then transform it into a sequence of Blocks ready for insertion. - Invoke the `replaceSelectedTextWithBlocks` command to replace the current selection with blocks (given the complexity of block combinations, this command uses [ts-pattern](https://github.com/gvergnaud/ts-pattern) implementation to ensure compile-time prevention of pattern handling omissions). 2. Optimized unit test assertions for commands, now allowing direct document content comparison using `toEqualDoc`. ```ts const host = affine` <affine-page id="page"> <affine-note id="note"> <affine-paragraph id="paragraph-1">Hel<anchor />lo</affine-paragraph> <affine-paragraph id="paragraph-2">Wor<focus />ld</affine-paragraph> </affine-note> </affine-page> `; // .... const expected = affine` <affine-page id="page"> <affine-note id="note"> <affine-paragraph id="paragraph-1">Hel111</affine-paragraph> <affine-code id="code"></affine-code> <affine-paragraph id="paragraph-2">222ld</affine-paragraph> </affine-note> </affine-page> `; expect(host.doc).toEqualDoc(expected.doc); ``` 3. Added support for text cursors in unit test template syntax. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit > CLOSE BS-3278 - **New Features** - Introduced the ability to replace selected text in documents with blocks, supporting advanced merging and insertion scenarios for various block types. - **Bug Fixes** - Improved handling of text selection and cursor placement in document templates used for testing. - **Tests** - Added comprehensive tests for replacing selected text with blocks, including edge cases and complex selection scenarios. - Enhanced test utilities for document structure comparison and selection handling. - Updated end-to-end tests to verify correct replacement of selected text via AI-driven actions. - **Chores** - Added and updated dependencies to support new features. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
+521
@@ -0,0 +1,521 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import '../../helpers/affine-test-utils';
|
||||
|
||||
import type { TextSelection } from '@blocksuite/std';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { replaceSelectedTextWithBlocksCommand } from '../../../commands/model-crud/replace-selected-text-with-blocks';
|
||||
import { affine, block } from '../../helpers/affine-template';
|
||||
|
||||
describe('commands/model-crud', () => {
|
||||
describe('replaceSelectedTextWithBlocksCommand', () => {
|
||||
it('should replace selected text with blocks when both first and last blocks are mergable blocks', () => {
|
||||
const host = affine`
|
||||
<affine-page id="page">
|
||||
<affine-note id="note">
|
||||
<affine-paragraph id="paragraph-1">Hel<anchor />lo</affine-paragraph>
|
||||
<affine-paragraph id="paragraph-2">Wor<focus />ld</affine-paragraph>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
|
||||
const blocks = [
|
||||
block`<affine-paragraph id="111">111</affine-paragraph>`,
|
||||
block`<affine-code id="code"></affine-code>`,
|
||||
block`<affine-paragraph id="222">222</affine-paragraph>`,
|
||||
]
|
||||
.filter((b): b is NonNullable<typeof b> => b !== null)
|
||||
.map(b => b.model);
|
||||
|
||||
const textSelection = host.selection.value[0] as TextSelection;
|
||||
|
||||
host.command.exec(replaceSelectedTextWithBlocksCommand, {
|
||||
textSelection,
|
||||
blocks,
|
||||
});
|
||||
|
||||
const expected = affine`
|
||||
<affine-page id="page">
|
||||
<affine-note id="note">
|
||||
<affine-paragraph id="paragraph-1">Hel111</affine-paragraph>
|
||||
<affine-code id="code"></affine-code>
|
||||
<affine-paragraph id="paragraph-2">222ld</affine-paragraph>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
|
||||
expect(host.store).toEqualDoc(expected.store);
|
||||
});
|
||||
|
||||
it('should replace selected text with blocks when both first and last blocks are mergable blocks in single paragraph', () => {
|
||||
const host = affine`
|
||||
<affine-page id="page">
|
||||
<affine-note id="note">
|
||||
<affine-paragraph id="paragraph-1">Hel<anchor></anchor>lo Wor<focus></focus>ld</affine-paragraph>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
|
||||
const blocks = [
|
||||
block`<affine-paragraph id="111">111</affine-paragraph>`,
|
||||
block`<affine-code id="code"></affine-code>`,
|
||||
block`<affine-paragraph id="222">222</affine-paragraph>`,
|
||||
]
|
||||
.filter((b): b is NonNullable<typeof b> => b !== null)
|
||||
.map(b => b.model);
|
||||
|
||||
const textSelection = host.selection.value[0] as TextSelection;
|
||||
|
||||
host.command.exec(replaceSelectedTextWithBlocksCommand, {
|
||||
textSelection,
|
||||
blocks,
|
||||
});
|
||||
|
||||
const expected = affine`
|
||||
<affine-page id="page">
|
||||
<affine-note id="note">
|
||||
<affine-paragraph id="paragraph-1">Hel111</affine-paragraph>
|
||||
<affine-code id="code"></affine-code>
|
||||
<affine-paragraph id="222">222ld</affine-paragraph>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
|
||||
expect(host.store).toEqualDoc(expected.store);
|
||||
});
|
||||
|
||||
it('should replace selected text with blocks when blocks contains only one mergable block', () => {
|
||||
const host = affine`
|
||||
<affine-page id="page">
|
||||
<affine-note id="note">
|
||||
<affine-paragraph id="paragraph-1">Hel<anchor />lo</affine-paragraph>
|
||||
<affine-paragraph id="paragraph-2">Wor<focus />ld</affine-paragraph>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
|
||||
const blocks = [block`<affine-paragraph id="111">111</affine-paragraph>`]
|
||||
.filter((b): b is NonNullable<typeof b> => b !== null)
|
||||
.map(b => b.model);
|
||||
|
||||
const textSelection = host.selection.value[0] as TextSelection;
|
||||
|
||||
host.command.exec(replaceSelectedTextWithBlocksCommand, {
|
||||
textSelection,
|
||||
blocks,
|
||||
});
|
||||
|
||||
const expected = affine`
|
||||
<affine-page id="page">
|
||||
<affine-note id="note">
|
||||
<affine-paragraph id="paragraph-1">Hel111ld</affine-paragraph>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
|
||||
expect(host.store).toEqualDoc(expected.store);
|
||||
});
|
||||
|
||||
it('should replace selected text with blocks when blocks contains only one mergable block in single paragraph', () => {
|
||||
const host = affine`
|
||||
<affine-page id="page">
|
||||
<affine-note id="note">
|
||||
<affine-paragraph id="paragraph-1">Hel<anchor></anchor>lo Wor<focus></focus>ld</affine-paragraph>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
|
||||
const blocks = [block`<affine-paragraph id="111">111</affine-paragraph>`]
|
||||
.filter((b): b is NonNullable<typeof b> => b !== null)
|
||||
.map(b => b.model);
|
||||
|
||||
const textSelection = host.selection.value[0] as TextSelection;
|
||||
|
||||
host.command.exec(replaceSelectedTextWithBlocksCommand, {
|
||||
textSelection,
|
||||
blocks,
|
||||
});
|
||||
|
||||
const expected = affine`
|
||||
<affine-page id="page">
|
||||
<affine-note id="note">
|
||||
<affine-paragraph id="paragraph-1">Hel111ld</affine-paragraph>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
|
||||
expect(host.store).toEqualDoc(expected.store);
|
||||
});
|
||||
|
||||
it('should replace selected text with blocks when only first block is mergable block', () => {
|
||||
const host = affine`
|
||||
<affine-page>
|
||||
<affine-note>
|
||||
<affine-paragraph>Hel<anchor />lo</affine-paragraph>
|
||||
<affine-paragraph>Wor<focus />ld</affine-paragraph>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
|
||||
const blocks = [
|
||||
block`<affine-paragraph>111</affine-paragraph>`,
|
||||
block`<affine-code></affine-code>`,
|
||||
block`<affine-code></affine-code>`,
|
||||
]
|
||||
.filter((b): b is NonNullable<typeof b> => b !== null)
|
||||
.map(b => b.model);
|
||||
|
||||
const textSelection = host.selection.value[0] as TextSelection;
|
||||
|
||||
host.command.exec(replaceSelectedTextWithBlocksCommand, {
|
||||
textSelection,
|
||||
blocks,
|
||||
});
|
||||
|
||||
const expected = affine`
|
||||
<affine-page>
|
||||
<affine-note >
|
||||
<affine-paragraph>Hel111</affine-paragraph>
|
||||
<affine-code></affine-code>
|
||||
<affine-code></affine-code>
|
||||
<affine-paragraph>ld</affine-paragraph>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
|
||||
expect(host.store).toEqualDoc(expected.store);
|
||||
});
|
||||
|
||||
it('should replace selected text with blocks when only first block is mergable block in single paragraph', () => {
|
||||
const host = affine`
|
||||
<affine-page>
|
||||
<affine-note>
|
||||
<affine-paragraph>Hel<anchor></anchor>lo Wor<focus></focus>ld</affine-paragraph>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
|
||||
const blocks = [
|
||||
block`<affine-paragraph>111</affine-paragraph>`,
|
||||
block`<affine-code></affine-code>`,
|
||||
block`<affine-code></affine-code>`,
|
||||
]
|
||||
.filter((b): b is NonNullable<typeof b> => b !== null)
|
||||
.map(b => b.model);
|
||||
|
||||
const textSelection = host.selection.value[0] as TextSelection;
|
||||
|
||||
host.command.exec(replaceSelectedTextWithBlocksCommand, {
|
||||
textSelection,
|
||||
blocks,
|
||||
});
|
||||
|
||||
const expected = affine`
|
||||
<affine-page>
|
||||
<affine-note>
|
||||
<affine-paragraph>Hel111</affine-paragraph>
|
||||
<affine-code></affine-code>
|
||||
<affine-code></affine-code>
|
||||
<affine-paragraph>ld</affine-paragraph>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
|
||||
expect(host.store).toEqualDoc(expected.store);
|
||||
});
|
||||
|
||||
it('should replace selected text with blocks when only last block is mergable block', () => {
|
||||
const host = affine`
|
||||
<affine-page>
|
||||
<affine-note>
|
||||
<affine-paragraph>Hel<anchor />lo</affine-paragraph>
|
||||
<affine-paragraph>Wor<focus />ld</affine-paragraph>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
|
||||
const blocks = [
|
||||
block`<affine-code></affine-code>`,
|
||||
block`<affine-code></affine-code>`,
|
||||
block`<affine-paragraph>111</affine-paragraph>`,
|
||||
]
|
||||
.filter((b): b is NonNullable<typeof b> => b !== null)
|
||||
.map(b => b.model);
|
||||
|
||||
const textSelection = host.selection.value[0] as TextSelection;
|
||||
|
||||
host.command.exec(replaceSelectedTextWithBlocksCommand, {
|
||||
textSelection,
|
||||
blocks,
|
||||
});
|
||||
|
||||
const expected = affine`
|
||||
<affine-page>
|
||||
<affine-note >
|
||||
<affine-paragraph>Hel</affine-paragraph>
|
||||
<affine-code></affine-code>
|
||||
<affine-code></affine-code>
|
||||
<affine-paragraph>111ld</affine-paragraph>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
expect(host.store).toEqualDoc(expected.store);
|
||||
});
|
||||
|
||||
it('should replace selected text with blocks when only last block is mergable block in single paragraph', () => {
|
||||
const host = affine`
|
||||
<affine-page>
|
||||
<affine-note>
|
||||
<affine-paragraph>Hel<anchor></anchor>lo Wor<focus></focus>ld</affine-paragraph>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
|
||||
const blocks = [
|
||||
block`<affine-code></affine-code>`,
|
||||
block`<affine-code></affine-code>`,
|
||||
block`<affine-paragraph>111</affine-paragraph>`,
|
||||
]
|
||||
.filter((b): b is NonNullable<typeof b> => b !== null)
|
||||
.map(b => b.model);
|
||||
|
||||
const textSelection = host.selection.value[0] as TextSelection;
|
||||
|
||||
host.command.exec(replaceSelectedTextWithBlocksCommand, {
|
||||
textSelection,
|
||||
blocks,
|
||||
});
|
||||
|
||||
const expected = affine`
|
||||
<affine-page>
|
||||
<affine-note>
|
||||
<affine-paragraph>Hel</affine-paragraph>
|
||||
<affine-code></affine-code>
|
||||
<affine-code></affine-code>
|
||||
<affine-paragraph>111ld</affine-paragraph>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
expect(host.store).toEqualDoc(expected.store);
|
||||
});
|
||||
|
||||
it('should replace selected text with blocks when neither first nor last block is mergable block', () => {
|
||||
const host = affine`
|
||||
<affine-page>
|
||||
<affine-note>
|
||||
<affine-paragraph>Hel<anchor />lo</affine-paragraph>
|
||||
<affine-paragraph>Wor<focus />ld</affine-paragraph>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
|
||||
const blocks = [
|
||||
block`<affine-code></affine-code>`,
|
||||
block`<affine-code></affine-code>`,
|
||||
]
|
||||
.filter((b): b is NonNullable<typeof b> => b !== null)
|
||||
.map(b => b.model);
|
||||
|
||||
const textSelection = host.selection.value[0] as TextSelection;
|
||||
|
||||
host.command.exec(replaceSelectedTextWithBlocksCommand, {
|
||||
textSelection,
|
||||
blocks,
|
||||
});
|
||||
|
||||
const expected = affine`
|
||||
<affine-page>
|
||||
<affine-note >
|
||||
<affine-paragraph>Hel</affine-paragraph>
|
||||
<affine-code></affine-code>
|
||||
<affine-code></affine-code>
|
||||
<affine-paragraph>ld</affine-paragraph>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
expect(host.store).toEqualDoc(expected.store);
|
||||
});
|
||||
|
||||
it('should replace selected text with blocks when neither first nor last block is mergable block in single paragraph', () => {
|
||||
const host = affine`
|
||||
<affine-page>
|
||||
<affine-note>
|
||||
<affine-paragraph>Hel<anchor></anchor>lo Wor<focus></focus>ld</affine-paragraph>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
|
||||
const blocks = [
|
||||
block`<affine-code></affine-code>`,
|
||||
block`<affine-code></affine-code>`,
|
||||
]
|
||||
.filter((b): b is NonNullable<typeof b> => b !== null)
|
||||
.map(b => b.model);
|
||||
|
||||
const textSelection = host.selection.value[0] as TextSelection;
|
||||
|
||||
host.command.exec(replaceSelectedTextWithBlocksCommand, {
|
||||
textSelection,
|
||||
blocks,
|
||||
});
|
||||
|
||||
const expected = affine`
|
||||
<affine-page>
|
||||
<affine-note>
|
||||
<affine-paragraph>Hel</affine-paragraph>
|
||||
<affine-code></affine-code>
|
||||
<affine-code></affine-code>
|
||||
<affine-paragraph>ld</affine-paragraph>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
expect(host.store).toEqualDoc(expected.store);
|
||||
});
|
||||
|
||||
it('should replace selected text with blocks when both first and last blocks are mergable blocks with different types', () => {
|
||||
const host = affine`
|
||||
<affine-page>
|
||||
<affine-note>
|
||||
<affine-paragraph>Hel<anchor />lo</affine-paragraph>
|
||||
<affine-paragraph>Wor<focus />ld</affine-paragraph>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
|
||||
const blocks = [
|
||||
block`<affine-list>1.</affine-list>`,
|
||||
block`<affine-list>2.</affine-list>`,
|
||||
]
|
||||
.filter((b): b is NonNullable<typeof b> => b !== null)
|
||||
.map(b => b.model);
|
||||
|
||||
const textSelection = host.selection.value[0] as TextSelection;
|
||||
|
||||
host.command.exec(replaceSelectedTextWithBlocksCommand, {
|
||||
textSelection,
|
||||
blocks,
|
||||
});
|
||||
|
||||
const expected = affine`
|
||||
<affine-page>
|
||||
<affine-note >
|
||||
<affine-paragraph>Hel</affine-paragraph>
|
||||
<affine-list>1.</affine-list>
|
||||
<affine-list>2.</affine-list>
|
||||
<affine-paragraph>ld</affine-paragraph>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
expect(host.store).toEqualDoc(expected.store);
|
||||
});
|
||||
|
||||
it('should replace selected text with blocks when both first and last blocks are paragraphs, and cursor is at the end of the text-block with different types', () => {
|
||||
const host = affine`
|
||||
<affine-page>
|
||||
<affine-note>
|
||||
<affine-list>Hel<anchor />lo</affine-list>
|
||||
<affine-list>Wor<focus />ld</affine-list>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
|
||||
const blocks = [
|
||||
block`<affine-paragraph>111</affine-paragraph>`,
|
||||
block`<affine-paragraph>222</affine-paragraph>`,
|
||||
]
|
||||
.filter((b): b is NonNullable<typeof b> => b !== null)
|
||||
.map(b => b.model);
|
||||
|
||||
const textSelection = host.selection.value[0] as TextSelection;
|
||||
|
||||
host.command.exec(replaceSelectedTextWithBlocksCommand, {
|
||||
textSelection,
|
||||
blocks,
|
||||
});
|
||||
|
||||
const expected = affine`
|
||||
<affine-page>
|
||||
<affine-note >
|
||||
<affine-list>Hel111</affine-list>
|
||||
<affine-list>222ld</affine-list>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
expect(host.store).toEqualDoc(expected.store);
|
||||
});
|
||||
|
||||
it('should replace selected text with blocks when first block is paragraph, and cursor is at the end of the text-block with different type ', () => {
|
||||
const host = affine`
|
||||
<affine-page>
|
||||
<affine-note>
|
||||
<affine-list>Hel<anchor />lo</affine-list>
|
||||
<affine-list>Wor<focus />ld</affine-list>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
|
||||
const blocks = [
|
||||
block`<affine-paragraph>111</affine-paragraph>`,
|
||||
block`<affine-code></affine-code>`,
|
||||
]
|
||||
.filter((b): b is NonNullable<typeof b> => b !== null)
|
||||
.map(b => b.model);
|
||||
|
||||
const textSelection = host.selection.value[0] as TextSelection;
|
||||
|
||||
host.command.exec(replaceSelectedTextWithBlocksCommand, {
|
||||
textSelection,
|
||||
blocks,
|
||||
});
|
||||
|
||||
const expected = affine`
|
||||
<affine-page>
|
||||
<affine-note >
|
||||
<affine-list>Hel111</affine-list>
|
||||
<affine-code></affine-code>
|
||||
<affine-list>ld</affine-list>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
expect(host.store).toEqualDoc(expected.store);
|
||||
});
|
||||
|
||||
it('should replace selected text with blocks when last block is paragraph, and cursor is at the end of the text-block with different type ', () => {
|
||||
const host = affine`
|
||||
<affine-page>
|
||||
<affine-note>
|
||||
<affine-list>Hel<anchor />lo</affine-list>
|
||||
<affine-list>Wor<focus />ld</affine-list>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
|
||||
const blocks = [
|
||||
block`<affine-code></affine-code>`,
|
||||
block`<affine-paragraph>222</affine-paragraph>`,
|
||||
]
|
||||
.filter((b): b is NonNullable<typeof b> => b !== null)
|
||||
.map(b => b.model);
|
||||
|
||||
const textSelection = host.selection.value[0] as TextSelection;
|
||||
|
||||
host.command.exec(replaceSelectedTextWithBlocksCommand, {
|
||||
textSelection,
|
||||
blocks,
|
||||
});
|
||||
|
||||
const expected = affine`
|
||||
<affine-page>
|
||||
<affine-note >
|
||||
<affine-list>Hel</affine-list>
|
||||
<affine-code></affine-code>
|
||||
<affine-list>222ld</affine-list>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
expect(host.store).toEqualDoc(expected.store);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
CodeBlockSchemaExtension,
|
||||
DatabaseBlockSchemaExtension,
|
||||
ImageBlockSchemaExtension,
|
||||
ListBlockSchemaExtension,
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
ParagraphBlockSchemaExtension,
|
||||
RootBlockSchemaExtension,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { TextSelection } from '@blocksuite/std';
|
||||
import { type Block, type Store } from '@blocksuite/store';
|
||||
import { Text } from '@blocksuite/store';
|
||||
import { TestWorkspace } from '@blocksuite/store/test';
|
||||
@@ -20,6 +22,7 @@ const extensions = [
|
||||
ListBlockSchemaExtension,
|
||||
ImageBlockSchemaExtension,
|
||||
DatabaseBlockSchemaExtension,
|
||||
CodeBlockSchemaExtension,
|
||||
];
|
||||
|
||||
// Mapping from tag names to flavours
|
||||
@@ -30,8 +33,18 @@ const tagToFlavour: Record<string, string> = {
|
||||
'affine-list': 'affine:list',
|
||||
'affine-image': 'affine:image',
|
||||
'affine-database': 'affine:database',
|
||||
'affine-code': 'affine:code',
|
||||
};
|
||||
|
||||
interface SelectionInfo {
|
||||
anchorBlockId?: string;
|
||||
anchorOffset?: number;
|
||||
focusBlockId?: string;
|
||||
focusOffset?: number;
|
||||
cursorBlockId?: string;
|
||||
cursorOffset?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse template strings and build BlockSuite document structure,
|
||||
* then create a host object with the document
|
||||
@@ -41,7 +54,8 @@ const tagToFlavour: Record<string, string> = {
|
||||
* const host = affine`
|
||||
* <affine-page id="page">
|
||||
* <affine-note id="note">
|
||||
* <affine-paragraph id="paragraph-1">Hello, world</affine-paragraph>
|
||||
* <affine-paragraph id="paragraph-1">Hello, world<anchor /></affine-paragraph>
|
||||
* <affine-paragraph id="paragraph-2">Hello, world<focus /></affine-paragraph>
|
||||
* </affine-note>
|
||||
* </affine-page>
|
||||
* `;
|
||||
@@ -63,6 +77,8 @@ export function affine(strings: TemplateStringsArray, ...values: any[]) {
|
||||
const doc = workspace.createDoc('test-doc');
|
||||
const store = doc.getStore({ extensions });
|
||||
|
||||
let selectionInfo: SelectionInfo = {};
|
||||
|
||||
// Use DOMParser to parse HTML string
|
||||
doc.load(() => {
|
||||
const parser = new DOMParser();
|
||||
@@ -73,11 +89,57 @@ export function affine(strings: TemplateStringsArray, ...values: any[]) {
|
||||
throw new Error('Template must contain a root element');
|
||||
}
|
||||
|
||||
buildDocFromElement(store, root, null);
|
||||
buildDocFromElement(store, root, null, selectionInfo);
|
||||
});
|
||||
|
||||
// Create and return a host object with the document
|
||||
return createTestHost(store);
|
||||
// Create host object
|
||||
const host = createTestHost(store);
|
||||
|
||||
// Set selection if needed
|
||||
if (selectionInfo.anchorBlockId && selectionInfo.focusBlockId) {
|
||||
const anchorBlock = store.getBlock(selectionInfo.anchorBlockId);
|
||||
const anchorTextLength = anchorBlock?.model?.text?.length ?? 0;
|
||||
const focusOffset = selectionInfo.focusOffset ?? 0;
|
||||
const anchorOffset = selectionInfo.anchorOffset ?? 0;
|
||||
|
||||
if (selectionInfo.anchorBlockId === selectionInfo.focusBlockId) {
|
||||
const selection = host.selection.create(TextSelection, {
|
||||
from: {
|
||||
blockId: selectionInfo.anchorBlockId,
|
||||
index: anchorOffset,
|
||||
length: focusOffset,
|
||||
},
|
||||
to: null,
|
||||
});
|
||||
host.selection.setGroup('note', [selection]);
|
||||
} else {
|
||||
const selection = host.selection.create(TextSelection, {
|
||||
from: {
|
||||
blockId: selectionInfo.anchorBlockId,
|
||||
index: anchorOffset,
|
||||
length: anchorTextLength - anchorOffset,
|
||||
},
|
||||
to: {
|
||||
blockId: selectionInfo.focusBlockId,
|
||||
index: 0,
|
||||
length: focusOffset,
|
||||
},
|
||||
});
|
||||
host.selection.setGroup('note', [selection]);
|
||||
}
|
||||
} else if (selectionInfo.cursorBlockId) {
|
||||
const selection = host.selection.create(TextSelection, {
|
||||
from: {
|
||||
blockId: selectionInfo.cursorBlockId,
|
||||
index: selectionInfo.cursorOffset ?? 0,
|
||||
length: 0,
|
||||
},
|
||||
to: null,
|
||||
});
|
||||
host.selection.setGroup('note', [selection]);
|
||||
}
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,6 +170,7 @@ export function block(
|
||||
const store = doc.getStore({ extensions });
|
||||
|
||||
let blockId: string | null = null;
|
||||
const selectionInfo: SelectionInfo = {};
|
||||
|
||||
// Use DOMParser to parse HTML string
|
||||
doc.load(() => {
|
||||
@@ -119,7 +182,19 @@ export function block(
|
||||
throw new Error('Template must contain a root element');
|
||||
}
|
||||
|
||||
blockId = buildDocFromElement(store, root, null);
|
||||
// Create a root block if needed
|
||||
const flavour = tagToFlavour[root.tagName.toLowerCase()];
|
||||
if (
|
||||
flavour === 'affine:paragraph' ||
|
||||
flavour === 'affine:list' ||
|
||||
flavour === 'affine:code'
|
||||
) {
|
||||
const pageId = store.addBlock('affine:page', {});
|
||||
const noteId = store.addBlock('affine:note', {}, pageId);
|
||||
blockId = buildDocFromElement(store, root, noteId, selectionInfo);
|
||||
} else {
|
||||
blockId = buildDocFromElement(store, root, null, selectionInfo);
|
||||
}
|
||||
});
|
||||
|
||||
// Return the created block
|
||||
@@ -131,14 +206,47 @@ export function block(
|
||||
* @param doc
|
||||
* @param element
|
||||
* @param parentId
|
||||
* @param selectionInfo
|
||||
* @returns
|
||||
*/
|
||||
function buildDocFromElement(
|
||||
doc: Store,
|
||||
element: Element,
|
||||
parentId: string | null
|
||||
parentId: string | null,
|
||||
selectionInfo: SelectionInfo
|
||||
): string {
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
|
||||
// Handle selection tags
|
||||
if (tagName === 'anchor') {
|
||||
if (!parentId) return '';
|
||||
const parentBlock = doc.getBlock(parentId);
|
||||
if (parentBlock) {
|
||||
const textBeforeCursor = element.previousSibling?.textContent ?? '';
|
||||
selectionInfo.anchorBlockId = parentId;
|
||||
selectionInfo.anchorOffset = textBeforeCursor.length;
|
||||
}
|
||||
return parentId;
|
||||
} else if (tagName === 'focus') {
|
||||
if (!parentId) return '';
|
||||
const parentBlock = doc.getBlock(parentId);
|
||||
if (parentBlock) {
|
||||
const textBeforeCursor = element.previousSibling?.textContent ?? '';
|
||||
selectionInfo.focusBlockId = parentId;
|
||||
selectionInfo.focusOffset = textBeforeCursor.length;
|
||||
}
|
||||
return parentId;
|
||||
} else if (tagName === 'cursor') {
|
||||
if (!parentId) return '';
|
||||
const parentBlock = doc.getBlock(parentId);
|
||||
if (parentBlock) {
|
||||
const textBeforeCursor = element.previousSibling?.textContent ?? '';
|
||||
selectionInfo.cursorBlockId = parentId;
|
||||
selectionInfo.cursorOffset = textBeforeCursor.length;
|
||||
}
|
||||
return parentId;
|
||||
}
|
||||
|
||||
const flavour = tagToFlavour[tagName];
|
||||
|
||||
if (!flavour) {
|
||||
@@ -175,9 +283,15 @@ function buildDocFromElement(
|
||||
// Create block
|
||||
const blockId = doc.addBlock(flavour, props, parentId);
|
||||
|
||||
// Recursively process child elements
|
||||
// Process all child nodes, including text nodes
|
||||
Array.from(element.children).forEach(child => {
|
||||
buildDocFromElement(doc, child, blockId);
|
||||
if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
// Handle element nodes
|
||||
buildDocFromElement(doc, child as Element, blockId, selectionInfo);
|
||||
} else if (child.nodeType === Node.TEXT_NODE) {
|
||||
// Handle text nodes
|
||||
console.log('buildDocFromElement text node:', child.textContent);
|
||||
}
|
||||
});
|
||||
|
||||
return blockId;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TextSelection } from '@blocksuite/std';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { affine } from './affine-template';
|
||||
@@ -80,4 +81,79 @@ describe('helpers/affine-template', () => {
|
||||
`;
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should handle text selection with anchor and focus', () => {
|
||||
const host = affine`
|
||||
<affine-page id="page">
|
||||
<affine-note id="note">
|
||||
<affine-paragraph id="paragraph-1">Hel<anchor />lo</affine-paragraph>
|
||||
<affine-paragraph id="paragraph-2">Wo<focus />rld</affine-paragraph>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
|
||||
const selection = host.selection.value[0] as TextSelection;
|
||||
expect(selection).toBeDefined();
|
||||
expect(selection.is(TextSelection)).toBe(true);
|
||||
expect(selection.from.blockId).toBe('paragraph-1');
|
||||
expect(selection.from.index).toBe(3);
|
||||
expect(selection.from.length).toBe(2);
|
||||
expect(selection.to?.blockId).toBe('paragraph-2');
|
||||
expect(selection.to?.index).toBe(0);
|
||||
expect(selection.to?.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle cursor position', () => {
|
||||
const host = affine`
|
||||
<affine-page id="page">
|
||||
<affine-note id="note">
|
||||
<affine-paragraph id="paragraph-1">Hello<cursor />World</affine-paragraph>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
|
||||
const selection = host.selection.value[0] as TextSelection;
|
||||
expect(selection).toBeDefined();
|
||||
expect(selection.is(TextSelection)).toBe(true);
|
||||
expect(selection.from.blockId).toBe('paragraph-1');
|
||||
expect(selection.from.index).toBe(5);
|
||||
expect(selection.from.length).toBe(0);
|
||||
expect(selection.to).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle selection in empty blocks', () => {
|
||||
const host = affine`
|
||||
<affine-page id="page">
|
||||
<affine-note id="note">
|
||||
<affine-paragraph id="paragraph-1"><cursor /></affine-paragraph>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
|
||||
const selection = host.selection.value[0] as TextSelection;
|
||||
expect(selection).toBeDefined();
|
||||
expect(selection.is(TextSelection)).toBe(true);
|
||||
expect(selection.from.blockId).toBe('paragraph-1');
|
||||
expect(selection.from.index).toBe(0);
|
||||
expect(selection.from.length).toBe(0);
|
||||
expect(selection.to).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle single point selection', () => {
|
||||
const host = affine`
|
||||
<affine-page id="page">
|
||||
<affine-note id="note">
|
||||
<affine-paragraph id="paragraph-1">Hello<anchor></anchor>World<focus></focus>Affine</affine-paragraph>
|
||||
</affine-note>
|
||||
</affine-page>
|
||||
`;
|
||||
|
||||
const selection = host.selection.value[0] as TextSelection;
|
||||
expect(selection).toBeDefined();
|
||||
expect(selection.is(TextSelection)).toBe(true);
|
||||
expect(selection.from.blockId).toBe('paragraph-1');
|
||||
expect(selection.from.index).toBe(5);
|
||||
expect(selection.from.length).toBe(5);
|
||||
expect(selection.to).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { BlockModel, Store } from '@blocksuite/store';
|
||||
import { expect } from 'vitest';
|
||||
|
||||
declare module 'vitest' {
|
||||
interface Assertion<T = any> {
|
||||
toEqualDoc(expected: Store, options?: { compareId?: boolean }): T;
|
||||
}
|
||||
}
|
||||
|
||||
const COMPARE_PROPERTIES = new Set(['id']);
|
||||
|
||||
function blockToTemplate(block: BlockModel, indent: string = ''): string {
|
||||
const props = Object.entries(block.props)
|
||||
.filter(([key]) => COMPARE_PROPERTIES.has(key))
|
||||
.map(([key, value]) => `${key}="${value}"`)
|
||||
.join(' ');
|
||||
|
||||
const text = block.text ? block.text.toString() : '';
|
||||
const children = block.children
|
||||
.map(child => blockToTemplate(child, indent + ' '))
|
||||
.join('\n');
|
||||
|
||||
const tagName = `affine-${block.flavour}`;
|
||||
const propsStr = props ? ` ${props}` : '';
|
||||
const content = text
|
||||
? `>${text}</${tagName}>`
|
||||
: children
|
||||
? `>\n${children}\n${indent}</${tagName}>`
|
||||
: `></${tagName}>`;
|
||||
|
||||
return `${indent}<${tagName}${propsStr}${content}`;
|
||||
}
|
||||
|
||||
function docToTemplate(doc: Store): string {
|
||||
if (!doc.root) return 'null';
|
||||
const rootBlock = doc.getBlock(doc.root.id);
|
||||
if (!rootBlock) return 'null';
|
||||
return blockToTemplate(rootBlock.model);
|
||||
}
|
||||
|
||||
function compareBlocks(
|
||||
actual: BlockModel,
|
||||
expected: BlockModel,
|
||||
compareId: boolean = false
|
||||
): boolean {
|
||||
if (actual.flavour !== expected.flavour) return false;
|
||||
if (compareId && actual.id !== expected.id) return false;
|
||||
if (actual.children.length !== expected.children.length) return false;
|
||||
|
||||
const actualText = actual.text;
|
||||
const expectedText = expected.text;
|
||||
if (
|
||||
actualText &&
|
||||
expectedText &&
|
||||
actualText.toString() !== expectedText.toString()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const actualProps = { ...actual.props };
|
||||
const expectedProps = { ...expected.props };
|
||||
|
||||
if (JSON.stringify(actualProps) !== JSON.stringify(expectedProps))
|
||||
return false;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
||||
for (let i = 0; i < actual.children.length; i++) {
|
||||
if (!compareBlocks(actual.children[i], expected.children[i], compareId))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function compareDocs(
|
||||
actual: Store,
|
||||
expected: Store,
|
||||
compareId: boolean = false
|
||||
): boolean {
|
||||
if (!actual.root || !expected.root) return false;
|
||||
|
||||
const actualRoot = actual.getBlock(actual.root.id);
|
||||
const expectedRoot = expected.getBlock(expected.root.id);
|
||||
|
||||
if (!actualRoot || !expectedRoot) return false;
|
||||
|
||||
return compareBlocks(actualRoot.model, expectedRoot.model, compareId);
|
||||
}
|
||||
|
||||
expect.extend({
|
||||
toEqualDoc(
|
||||
received: Store,
|
||||
expected: Store,
|
||||
options: { compareId?: boolean } = { compareId: false }
|
||||
) {
|
||||
const compareId = options.compareId;
|
||||
const pass = compareDocs(received, expected, compareId);
|
||||
|
||||
if (pass) {
|
||||
return {
|
||||
message: () => 'expected documents to be different',
|
||||
pass: true,
|
||||
};
|
||||
} else {
|
||||
const actualTemplate = docToTemplate(received);
|
||||
const expectedTemplate = docToTemplate(expected);
|
||||
|
||||
return {
|
||||
message: () =>
|
||||
`Documents are not equal.\n\nActual:\n${actualTemplate}\n\nExpected:\n${expectedTemplate}`,
|
||||
pass: false,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -231,6 +231,7 @@ export function createTestHost(doc: Store): EditorHost {
|
||||
const host = {
|
||||
store: doc,
|
||||
std: std as any,
|
||||
selection: undefined as any,
|
||||
};
|
||||
host.store = doc;
|
||||
host.std = std as any;
|
||||
@@ -241,6 +242,7 @@ export function createTestHost(doc: Store): EditorHost {
|
||||
std.command = new CommandManager(std as any);
|
||||
// @ts-expect-error
|
||||
host.command = std.command;
|
||||
host.selection = std.selection;
|
||||
|
||||
return host as EditorHost;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user