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:
yoyoyohamapi
2025-05-08 11:48:18 +00:00
parent 6689bd1914
commit 6d012f093f
13 changed files with 1331 additions and 39 deletions

View File

@@ -1,5 +1,6 @@
import { deleteTextCommand } from '@blocksuite/affine/inlines/preset';
import { WorkspaceImpl } from '@affine/core/modules/workspace/impls/workspace';
import { defaultImageProxyMiddleware } from '@blocksuite/affine/shared/adapters';
import { replaceSelectedTextWithBlocksCommand } from '@blocksuite/affine/shared/commands';
import { isInsideEdgelessEditor } from '@blocksuite/affine/shared/utils';
import {
type BlockComponent,
@@ -8,7 +9,12 @@ import {
SurfaceSelection,
type TextSelection,
} from '@blocksuite/affine/std';
import { type BlockModel, Slice } from '@blocksuite/affine/store';
import {
type BlockModel,
type BlockSnapshot,
Slice,
} from '@blocksuite/affine/store';
import { Doc as YDoc } from 'yjs';
import {
insertFromMarkdown,
@@ -109,19 +115,53 @@ export const replace = async (
);
if (textSelection) {
host.std.command.exec(deleteTextCommand, { textSelection });
const { snapshot, transformer } = await markdownToSnapshot(
content,
host.store,
host
);
if (snapshot) {
await transformer.snapshotToSlice(
snapshot,
host.store,
firstBlockParent.model.id,
firstIndex + 1
const collection = new WorkspaceImpl({
id: 'AI_REPLACE',
rootDoc: new YDoc({ guid: 'AI_REPLACE' }),
});
collection.meta.initialize();
const fragmentDoc = collection.createDoc();
try {
const fragment = fragmentDoc.getStore();
fragmentDoc.load();
const rootId = fragment.addBlock('affine:page');
fragment.addBlock('affine:surface', {}, rootId);
const noteId = fragment.addBlock('affine:note', {}, rootId);
const { snapshot, transformer } = await markdownToSnapshot(
content,
fragment,
host
);
if (snapshot) {
const blockSnapshots = (
snapshot.content[0].flavour === 'affine:note'
? snapshot.content[0].children
: snapshot.content
) as BlockSnapshot[];
const blocks = (
await Promise.all(
blockSnapshots.map(async blockSnapshot => {
return await transformer.snapshotToBlock(
blockSnapshot,
fragment,
noteId,
0
);
})
)
).filter(block => block) as BlockModel[];
host.std.command.exec(replaceSelectedTextWithBlocksCommand, {
textSelection,
blocks,
});
}
} finally {
collection.dispose();
}
} else {
selectedModels.forEach(model => {