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:
@@ -44,6 +44,7 @@
|
|||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
"remark-stringify": "^11.0.0",
|
"remark-stringify": "^11.0.0",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"ts-pattern": "^5.1.0",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
"unist-util-visit": "^5.0.0",
|
"unist-util-visit": "^5.0.0",
|
||||||
"yjs": "^13.6.21",
|
"yjs": "^13.6.21",
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
|
CodeBlockSchemaExtension,
|
||||||
DatabaseBlockSchemaExtension,
|
DatabaseBlockSchemaExtension,
|
||||||
ImageBlockSchemaExtension,
|
ImageBlockSchemaExtension,
|
||||||
ListBlockSchemaExtension,
|
ListBlockSchemaExtension,
|
||||||
@@ -6,6 +7,7 @@ import {
|
|||||||
ParagraphBlockSchemaExtension,
|
ParagraphBlockSchemaExtension,
|
||||||
RootBlockSchemaExtension,
|
RootBlockSchemaExtension,
|
||||||
} from '@blocksuite/affine-model';
|
} from '@blocksuite/affine-model';
|
||||||
|
import { TextSelection } from '@blocksuite/std';
|
||||||
import { type Block, type Store } from '@blocksuite/store';
|
import { type Block, type Store } from '@blocksuite/store';
|
||||||
import { Text } from '@blocksuite/store';
|
import { Text } from '@blocksuite/store';
|
||||||
import { TestWorkspace } from '@blocksuite/store/test';
|
import { TestWorkspace } from '@blocksuite/store/test';
|
||||||
@@ -20,6 +22,7 @@ const extensions = [
|
|||||||
ListBlockSchemaExtension,
|
ListBlockSchemaExtension,
|
||||||
ImageBlockSchemaExtension,
|
ImageBlockSchemaExtension,
|
||||||
DatabaseBlockSchemaExtension,
|
DatabaseBlockSchemaExtension,
|
||||||
|
CodeBlockSchemaExtension,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Mapping from tag names to flavours
|
// Mapping from tag names to flavours
|
||||||
@@ -30,8 +33,18 @@ const tagToFlavour: Record<string, string> = {
|
|||||||
'affine-list': 'affine:list',
|
'affine-list': 'affine:list',
|
||||||
'affine-image': 'affine:image',
|
'affine-image': 'affine:image',
|
||||||
'affine-database': 'affine:database',
|
'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,
|
* Parse template strings and build BlockSuite document structure,
|
||||||
* then create a host object with the document
|
* then create a host object with the document
|
||||||
@@ -41,7 +54,8 @@ const tagToFlavour: Record<string, string> = {
|
|||||||
* const host = affine`
|
* const host = affine`
|
||||||
* <affine-page id="page">
|
* <affine-page id="page">
|
||||||
* <affine-note id="note">
|
* <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-note>
|
||||||
* </affine-page>
|
* </affine-page>
|
||||||
* `;
|
* `;
|
||||||
@@ -63,6 +77,8 @@ export function affine(strings: TemplateStringsArray, ...values: any[]) {
|
|||||||
const doc = workspace.createDoc('test-doc');
|
const doc = workspace.createDoc('test-doc');
|
||||||
const store = doc.getStore({ extensions });
|
const store = doc.getStore({ extensions });
|
||||||
|
|
||||||
|
let selectionInfo: SelectionInfo = {};
|
||||||
|
|
||||||
// Use DOMParser to parse HTML string
|
// Use DOMParser to parse HTML string
|
||||||
doc.load(() => {
|
doc.load(() => {
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
@@ -73,11 +89,57 @@ export function affine(strings: TemplateStringsArray, ...values: any[]) {
|
|||||||
throw new Error('Template must contain a root element');
|
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
|
// Create host object
|
||||||
return createTestHost(store);
|
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 });
|
const store = doc.getStore({ extensions });
|
||||||
|
|
||||||
let blockId: string | null = null;
|
let blockId: string | null = null;
|
||||||
|
const selectionInfo: SelectionInfo = {};
|
||||||
|
|
||||||
// Use DOMParser to parse HTML string
|
// Use DOMParser to parse HTML string
|
||||||
doc.load(() => {
|
doc.load(() => {
|
||||||
@@ -119,7 +182,19 @@ export function block(
|
|||||||
throw new Error('Template must contain a root element');
|
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
|
// Return the created block
|
||||||
@@ -131,14 +206,47 @@ export function block(
|
|||||||
* @param doc
|
* @param doc
|
||||||
* @param element
|
* @param element
|
||||||
* @param parentId
|
* @param parentId
|
||||||
|
* @param selectionInfo
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
function buildDocFromElement(
|
function buildDocFromElement(
|
||||||
doc: Store,
|
doc: Store,
|
||||||
element: Element,
|
element: Element,
|
||||||
parentId: string | null
|
parentId: string | null,
|
||||||
|
selectionInfo: SelectionInfo
|
||||||
): string {
|
): string {
|
||||||
const tagName = element.tagName.toLowerCase();
|
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];
|
const flavour = tagToFlavour[tagName];
|
||||||
|
|
||||||
if (!flavour) {
|
if (!flavour) {
|
||||||
@@ -175,9 +283,15 @@ function buildDocFromElement(
|
|||||||
// Create block
|
// Create block
|
||||||
const blockId = doc.addBlock(flavour, props, parentId);
|
const blockId = doc.addBlock(flavour, props, parentId);
|
||||||
|
|
||||||
// Recursively process child elements
|
// Process all child nodes, including text nodes
|
||||||
Array.from(element.children).forEach(child => {
|
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;
|
return blockId;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { TextSelection } from '@blocksuite/std';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { affine } from './affine-template';
|
import { affine } from './affine-template';
|
||||||
@@ -80,4 +81,79 @@ describe('helpers/affine-template', () => {
|
|||||||
`;
|
`;
|
||||||
}).toThrow();
|
}).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 = {
|
const host = {
|
||||||
store: doc,
|
store: doc,
|
||||||
std: std as any,
|
std: std as any,
|
||||||
|
selection: undefined as any,
|
||||||
};
|
};
|
||||||
host.store = doc;
|
host.store = doc;
|
||||||
host.std = std as any;
|
host.std = std as any;
|
||||||
@@ -241,6 +242,7 @@ export function createTestHost(doc: Store): EditorHost {
|
|||||||
std.command = new CommandManager(std as any);
|
std.command = new CommandManager(std as any);
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
host.command = std.command;
|
host.command = std.command;
|
||||||
|
host.selection = std.selection;
|
||||||
|
|
||||||
return host as EditorHost;
|
return host as EditorHost;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export {
|
|||||||
draftSelectedModelsCommand,
|
draftSelectedModelsCommand,
|
||||||
duplicateSelectedModelsCommand,
|
duplicateSelectedModelsCommand,
|
||||||
getSelectedModelsCommand,
|
getSelectedModelsCommand,
|
||||||
|
replaceSelectedTextWithBlocksCommand,
|
||||||
retainFirstModelCommand,
|
retainFirstModelCommand,
|
||||||
} from './model-crud/index.js';
|
} from './model-crud/index.js';
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ export { deleteSelectedModelsCommand } from './delete-selected-models.js';
|
|||||||
export { draftSelectedModelsCommand } from './draft-selected-models.js';
|
export { draftSelectedModelsCommand } from './draft-selected-models.js';
|
||||||
export { duplicateSelectedModelsCommand } from './duplicate-selected-model.js';
|
export { duplicateSelectedModelsCommand } from './duplicate-selected-model.js';
|
||||||
export { getSelectedModelsCommand } from './get-selected-models.js';
|
export { getSelectedModelsCommand } from './get-selected-models.js';
|
||||||
|
export { replaceSelectedTextWithBlocksCommand } from './replace-selected-text-with-blocks.js';
|
||||||
export { retainFirstModelCommand } from './retain-first-model.js';
|
export { retainFirstModelCommand } from './retain-first-model.js';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
};
|
||||||
@@ -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 { defaultImageProxyMiddleware } from '@blocksuite/affine/shared/adapters';
|
||||||
|
import { replaceSelectedTextWithBlocksCommand } from '@blocksuite/affine/shared/commands';
|
||||||
import { isInsideEdgelessEditor } from '@blocksuite/affine/shared/utils';
|
import { isInsideEdgelessEditor } from '@blocksuite/affine/shared/utils';
|
||||||
import {
|
import {
|
||||||
type BlockComponent,
|
type BlockComponent,
|
||||||
@@ -8,7 +9,12 @@ import {
|
|||||||
SurfaceSelection,
|
SurfaceSelection,
|
||||||
type TextSelection,
|
type TextSelection,
|
||||||
} from '@blocksuite/affine/std';
|
} 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 {
|
import {
|
||||||
insertFromMarkdown,
|
insertFromMarkdown,
|
||||||
@@ -109,19 +115,53 @@ export const replace = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (textSelection) {
|
if (textSelection) {
|
||||||
host.std.command.exec(deleteTextCommand, { textSelection });
|
const collection = new WorkspaceImpl({
|
||||||
const { snapshot, transformer } = await markdownToSnapshot(
|
id: 'AI_REPLACE',
|
||||||
content,
|
rootDoc: new YDoc({ guid: 'AI_REPLACE' }),
|
||||||
host.store,
|
});
|
||||||
host
|
collection.meta.initialize();
|
||||||
);
|
const fragmentDoc = collection.createDoc();
|
||||||
if (snapshot) {
|
|
||||||
await transformer.snapshotToSlice(
|
try {
|
||||||
snapshot,
|
const fragment = fragmentDoc.getStore();
|
||||||
host.store,
|
fragmentDoc.load();
|
||||||
firstBlockParent.model.id,
|
|
||||||
firstIndex + 1
|
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 {
|
} else {
|
||||||
selectedModels.forEach(model => {
|
selectedModels.forEach(model => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { IS_MAC } from '@blocksuite/global/env';
|
||||||
import { expect } from '@playwright/test';
|
import { expect } from '@playwright/test';
|
||||||
|
|
||||||
import { test } from '../base/base-test';
|
import { test } from '../base/base-test';
|
||||||
@@ -62,13 +63,22 @@ test.describe('AIChatWith/Text', () => {
|
|||||||
loggedInPage: page,
|
loggedInPage: page,
|
||||||
utils,
|
utils,
|
||||||
}) => {
|
}) => {
|
||||||
const { translate } = await utils.editor.askAIWithText(page, 'Apple');
|
await utils.editor.focusToEditor(page);
|
||||||
const { answer } = await translate('German');
|
await page.keyboard.insertText('I Loev Apple');
|
||||||
await expect(answer).toHaveText(/Apfel/, { timeout: 10000 });
|
|
||||||
|
// Select the word "Loev"
|
||||||
|
const SHORT_KEY = IS_MAC ? 'Alt' : 'Control';
|
||||||
|
await page.keyboard.press(`${SHORT_KEY}+ArrowLeft`);
|
||||||
|
await page.keyboard.press('ArrowLeft');
|
||||||
|
await page.keyboard.press(`Shift+${SHORT_KEY}+ArrowLeft`);
|
||||||
|
|
||||||
|
const { fixSpelling } = await utils.editor.showAIMenu(page);
|
||||||
|
const { answer } = await fixSpelling();
|
||||||
|
await expect(answer).toHaveText(/Love/, { timeout: 10000 });
|
||||||
const replace = answer.getByTestId('answer-replace');
|
const replace = answer.getByTestId('answer-replace');
|
||||||
await replace.click();
|
await replace.click();
|
||||||
const content = await utils.editor.getEditorContent(page);
|
const content = await utils.editor.getEditorContent(page);
|
||||||
expect(content).toBe('Apfel');
|
expect(content).toBe('I Love Apple');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should support continue in chat', async ({
|
test('should support continue in chat', async ({
|
||||||
|
|||||||
@@ -546,19 +546,7 @@ export class EditorUtils {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async askAIWithText(page: Page, text: string) {
|
public static async showAIMenu(page: Page) {
|
||||||
await this.focusToEditor(page);
|
|
||||||
const texts = text.split('\n');
|
|
||||||
for (const [index, line] of texts.entries()) {
|
|
||||||
await page.keyboard.insertText(line);
|
|
||||||
if (index !== texts.length - 1) {
|
|
||||||
await page.keyboard.press('Enter');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await page.keyboard.press('ControlOrMeta+A');
|
|
||||||
await page.keyboard.press('ControlOrMeta+A');
|
|
||||||
await page.keyboard.press('ControlOrMeta+A');
|
|
||||||
|
|
||||||
const askAI = await page.locator('page-editor editor-toolbar ask-ai-icon');
|
const askAI = await page.locator('page-editor editor-toolbar ask-ai-icon');
|
||||||
await askAI.waitFor({
|
await askAI.waitFor({
|
||||||
state: 'attached',
|
state: 'attached',
|
||||||
@@ -661,6 +649,22 @@ export class EditorUtils {
|
|||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async askAIWithText(page: Page, text: string) {
|
||||||
|
await this.focusToEditor(page);
|
||||||
|
const texts = text.split('\n');
|
||||||
|
for (const [index, line] of texts.entries()) {
|
||||||
|
await page.keyboard.insertText(line);
|
||||||
|
if (index !== texts.length - 1) {
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.keyboard.press('ControlOrMeta+A');
|
||||||
|
await page.keyboard.press('ControlOrMeta+A');
|
||||||
|
await page.keyboard.press('ControlOrMeta+A');
|
||||||
|
|
||||||
|
return await this.showAIMenu(page);
|
||||||
|
}
|
||||||
|
|
||||||
public static async whatAreYourThoughts(page: Page, text: string) {
|
public static async whatAreYourThoughts(page: Page, text: string) {
|
||||||
const textarea = page.locator(
|
const textarea = page.locator(
|
||||||
'affine-ai-panel-widget .ai-panel-container textarea'
|
'affine-ai-panel-widget .ai-panel-container textarea'
|
||||||
|
|||||||
@@ -3714,6 +3714,7 @@ __metadata:
|
|||||||
remark-parse: "npm:^11.0.0"
|
remark-parse: "npm:^11.0.0"
|
||||||
remark-stringify: "npm:^11.0.0"
|
remark-stringify: "npm:^11.0.0"
|
||||||
rxjs: "npm:^7.8.1"
|
rxjs: "npm:^7.8.1"
|
||||||
|
ts-pattern: "npm:^5.1.0"
|
||||||
unified: "npm:^11.0.5"
|
unified: "npm:^11.0.5"
|
||||||
unist-util-visit: "npm:^5.0.0"
|
unist-util-visit: "npm:^5.0.0"
|
||||||
vitest: "npm:3.1.3"
|
vitest: "npm:3.1.3"
|
||||||
@@ -33045,6 +33046,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"ts-pattern@npm:^5.1.0":
|
||||||
|
version: 5.7.0
|
||||||
|
resolution: "ts-pattern@npm:5.7.0"
|
||||||
|
checksum: 10/9a1dda9321a79c7c36209c9434dae4e51cb79c0df2bd15ac2bcd1fc193e1467bb876733d41bf786865646e6736b1d8535ccb40ae39b7cf3e39c4247c745a5eb5
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"tsconfig-paths@npm:^4.2.0":
|
"tsconfig-paths@npm:^4.2.0":
|
||||||
version: 4.2.0
|
version: 4.2.0
|
||||||
resolution: "tsconfig-paths@npm:4.2.0"
|
resolution: "tsconfig-paths@npm:4.2.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user