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

@@ -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);
});
});
});

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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,
};
}
},
});

View File

@@ -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;
}

View File

@@ -13,6 +13,7 @@ export {
draftSelectedModelsCommand,
duplicateSelectedModelsCommand,
getSelectedModelsCommand,
replaceSelectedTextWithBlocksCommand,
retainFirstModelCommand,
} from './model-crud/index.js';
export {

View File

@@ -4,4 +4,5 @@ export { deleteSelectedModelsCommand } from './delete-selected-models.js';
export { draftSelectedModelsCommand } from './draft-selected-models.js';
export { duplicateSelectedModelsCommand } from './duplicate-selected-model.js';
export { getSelectedModelsCommand } from './get-selected-models.js';
export { replaceSelectedTextWithBlocksCommand } from './replace-selected-text-with-blocks.js';
export { retainFirstModelCommand } from './retain-first-model.js';

View File

@@ -0,0 +1,399 @@
import type { BlockModel, Store } from '@blocksuite/affine/store';
import type { Command, TextSelection } from '@blocksuite/std';
import { match } from 'ts-pattern';
import { getBlockProps } from '../../utils';
/**
* Determines if blocks can be merged based on their types
* - Paragraphs can always be merged to
* - Other blocks can only be merged with blocks of the same type
* @FIXME: decouple the mergeable block types from the command
*/
const canMergeBlocks = (blockA: BlockModel, blockB: BlockModel): boolean => {
// Paragraphs can always be merged to
if (blockB.flavour === 'affine:paragraph') {
return true;
}
// Other blocks can only be merged with blocks of the same type
return blockA.flavour === blockB.flavour;
};
/**
* Check if a block is mergeable in general
* @FIXME: decouple the mergeable block types from the command
*/
const isMergableBlock = (block: BlockModel): boolean => {
// Blocks that can potentially be merged
const mergableTypes = ['affine:paragraph', 'affine:list'];
return mergableTypes.includes(block.flavour);
};
type SnapshotPattern = {
multiple: boolean;
canMergeWithStart?: boolean;
canMergeWithEnd?: boolean;
};
const getBlocksPattern = (
blocks: BlockModel[],
startBlockModel: BlockModel,
endBlockModel?: BlockModel
): SnapshotPattern => {
const firstBlock = blocks[0];
const lastBlock = blocks[blocks.length - 1];
const isFirstMergable = isMergableBlock(firstBlock);
const isLastMergable = isMergableBlock(lastBlock);
return {
multiple: blocks.length > 1,
canMergeWithStart:
isFirstMergable && canMergeBlocks(startBlockModel, firstBlock),
canMergeWithEnd:
isLastMergable && endBlockModel
? canMergeBlocks(endBlockModel, lastBlock)
: false,
};
};
const mergeText = (
targetModel: BlockModel,
sourceBlock: BlockModel,
offset: number
) => {
if (targetModel.text && sourceBlock.text) {
const sourceText = sourceBlock.text.toString();
if (sourceText.length > 0) {
targetModel.text.insert(sourceText, offset);
}
}
};
const splitParagraph = (
doc: any,
parent: any,
blockModel: BlockModel,
index: number,
splitOffset: number
): BlockModel => {
// Create a new block of the same type as the original
const newBlockId = doc.addBlock(blockModel.flavour, {}, parent, index + 1);
const nextBlock = doc.getBlock(newBlockId);
if (nextBlock?.model.text && blockModel.text) {
const textToMove = blockModel.text.toString().slice(splitOffset);
nextBlock.model.text.insert(textToMove, 0);
blockModel.text.delete(splitOffset, blockModel.text.length - splitOffset);
}
return nextBlock.model;
};
const getSelectedBlocks = (
doc: Store,
textSelection: TextSelection
): BlockModel[] | null => {
const selectedBlocks: BlockModel[] = [];
const fromBlock = doc.getBlock(textSelection.from.blockId)?.model;
if (!fromBlock) return null;
selectedBlocks.push(fromBlock);
// If the selection spans multiple blocks, add the blocks in between
if (textSelection.to) {
const toBlock = doc.getBlock(textSelection.to.blockId)?.model;
if (!toBlock) return null;
if (fromBlock.id !== toBlock.id) {
let currentBlock = fromBlock;
while (currentBlock.id !== toBlock.id) {
const nextBlock = doc.getNext(currentBlock);
if (!nextBlock) break;
selectedBlocks.push(nextBlock);
currentBlock = nextBlock;
}
}
}
return selectedBlocks;
};
const deleteSelectedText = (doc: Store, textSelection: TextSelection) => {
const selectedBlocks = getSelectedBlocks(doc, textSelection);
if (!selectedBlocks || selectedBlocks.length === 0) return null;
const firstBlock = selectedBlocks[0];
const lastBlock = selectedBlocks[selectedBlocks.length - 1];
const startOffset = textSelection.from.index;
if (textSelection.to) {
// Delete text from startOffset to the end in the first block
if (firstBlock.text) {
firstBlock.text.delete(startOffset, firstBlock.text.length - startOffset);
}
// Delete text from the beginning to endOffset in the last block
if (lastBlock.text) {
lastBlock.text.delete(textSelection.to.index, textSelection.to.length);
}
// Merge first block and last block
if (firstBlock.text && lastBlock.text) {
firstBlock.text.insert(lastBlock.text.toString(), startOffset);
}
// Delete the blocks in between
selectedBlocks.slice(1).forEach(block => {
doc.deleteBlock(block);
});
} else {
// Single block selection case
if (firstBlock.text) {
firstBlock.text.delete(startOffset, textSelection.from.length);
}
}
return { startBlockModel: firstBlock, endBlockModel: lastBlock, startOffset };
};
const addBlocks = (
doc: any,
blocks: BlockModel[],
parent: any,
from: number
) => {
blocks.forEach((block, index) => {
const blockProps = {
...getBlockProps(block),
text: block.text?.clone(),
children: block.children,
};
doc.addBlock(block.flavour, blockProps, parent, from + index);
});
};
/**
* Replace the selected text with the given blocks
*
* @warning This command is currently being optimized, please do not use it.
* @param ctx
* @param next
* @returns
*/
export const replaceSelectedTextWithBlocksCommand: Command<{
textSelection: TextSelection;
blocks: BlockModel[];
}> = (ctx, next) => {
const { textSelection, blocks, std } = ctx;
const doc = std.host.store;
// Delete selected text and get startOffset
const result = deleteSelectedText(doc, textSelection);
if (!result) return next();
const { startBlockModel, endBlockModel, startOffset } = result;
const parent = doc.getParent(startBlockModel.id);
if (!parent) return next();
const pattern = getBlocksPattern(blocks, startBlockModel, endBlockModel);
const startIndex = parent.children.findIndex(
x => x.id === startBlockModel.id
);
match(pattern)
.with({ multiple: false, canMergeWithStart: true }, () => {
/**
* Case: Single block that can merge with start block
*
* ```tsx
* const doc = (
* <doc>
* <paragraph>Hel<anchor />lo</paragraph>
* <paragraph>Wor<focus />ld</paragraph>
* </doc>
* );
*
* const snapshot = [
* <paragraph>111</paragraph>,
* ];
*
* const expected = (
* <doc>
* <paragraph>Hel111ld</paragraph>
* </doc>
* );
* ```
*/
mergeText(startBlockModel, blocks[0], startOffset);
})
.with(
{
multiple: true,
canMergeWithStart: true,
canMergeWithEnd: true,
},
() => {
/**
* Case: Both first and last blocks are mergable with start and end blocks
*
* ```tsx
* const doc = (
* <doc>
* <paragraph>Hel<anchor />lo</paragraph>
* <paragraph>Wor<focus />ld</paragraph>
* </doc>
* );
*
* const snapshot = [
* <paragraph>111</paragraph>,
* <code />
* <code />
* <paragraph>222</paragraph>,
* ];
*
* const expected = (
* <doc>
* <paragraph>Hel111</paragraph>
* <code />
* <code />
* <paragraph>222ld</paragraph>
* </doc>
* );
*/
const nextBlockModel = splitParagraph(
doc,
parent,
startBlockModel,
startIndex,
startOffset
);
mergeText(startBlockModel, blocks[0], startOffset);
mergeText(nextBlockModel, blocks[blocks.length - 1], 0);
const restBlocks = blocks.slice(1, -1);
if (restBlocks.length > 0) {
addBlocks(doc, restBlocks, parent, startIndex + 1);
}
}
)
.with(
{
multiple: true,
canMergeWithStart: true,
canMergeWithEnd: false,
},
() => {
/**
* Case: First block is mergable with start block, but last block isn't with end block
*
* ```tsx
* const doc = (
* <doc>
* <paragraph>Hel<anchor />lo</paragraph>
* <paragraph>Wor<focus />ld</paragraph>
* </doc>
* );
*
* const snapshot = [
* <paragraph>111</paragraph>,
* <code />
* <code />
* ];
*
* const expected = (
* <doc>
* <paragraph>Hel111</paragraph>
* <code />
* <code />
* <paragraph>ld</paragraph>
* </doc>
* );
* ```
*/
splitParagraph(doc, parent, startBlockModel, startIndex, startOffset);
mergeText(startBlockModel, blocks[0], startOffset);
const restBlocks = blocks.slice(1);
if (restBlocks.length > 0) {
addBlocks(doc, restBlocks, parent, startIndex + 1);
}
}
)
.with(
{
multiple: true,
canMergeWithStart: false,
canMergeWithEnd: true,
},
() => {
/**
* Case: First block isn't mergable with start block, but last block is with end block
*
* ```tsx
* const doc = (
* <doc>
* <paragraph>Hel<anchor />lo</paragraph>
* <paragraph>Wor<focus />ld</paragraph>
* </doc>
* );
*
* const snapshot = [
* <code />
* <code />
* <paragraph>222</paragraph>
* ];
*
* const expected = (
* <doc>
* <paragraph>Hel</paragraph>
* <code />
* <code />
* <paragraph>222ld</paragraph>
* </doc>
* );
* ```
*/
const nextBlockModel = splitParagraph(
doc,
parent,
startBlockModel,
startIndex,
startOffset
);
mergeText(nextBlockModel as BlockModel, blocks[blocks.length - 1], 0);
const restBlocks = blocks.slice(0, -1);
if (restBlocks.length > 0) {
addBlocks(doc, restBlocks, parent, startIndex + 1);
}
}
)
.otherwise(() => {
/**
* Default case: No mergable blocks or blocks that can't be merged
*
* ```tsx
* const doc = (
* <doc>
* <paragraph>Hel<anchor />lo</paragraph>
* <paragraph>Wor<focus />ld</paragraph>
* </doc>
* );
*
* const snapshot = [
* <code />
* <code />
* ];
*
* const expected = (
* <doc>
* <paragraph>Hel</paragraph>
* <code />
* <code />
* <paragraph>ld</paragraph>
* </doc>
* );
* ```
*/
splitParagraph(doc, parent, startBlockModel, startIndex, startOffset);
addBlocks(doc, blocks, parent, startIndex + 1);
});
return next();
};