mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-11 14:08:55 +08:00
fix(core): ai replace selection (#11875)
### TL;DR * Fix the issue of inaccurate content replacement in AI Replace Selection * Optimize unit Tests utils ### What Changed 1. Fixed the issue of inaccurate content replacement in AI Replace Selection: - Convert the AI Answer into a Snapshot, then transform it into a sequence of Blocks ready for insertion. - Invoke the `replaceSelectedTextWithBlocks` command to replace the current selection with blocks (given the complexity of block combinations, this command uses [ts-pattern](https://github.com/gvergnaud/ts-pattern) implementation to ensure compile-time prevention of pattern handling omissions). 2. Optimized unit test assertions for commands, now allowing direct document content comparison using `toEqualDoc`. ```ts const host = affine` <affine-page id="page"> <affine-note id="note"> <affine-paragraph id="paragraph-1">Hel<anchor />lo</affine-paragraph> <affine-paragraph id="paragraph-2">Wor<focus />ld</affine-paragraph> </affine-note> </affine-page> `; // .... const expected = affine` <affine-page id="page"> <affine-note id="note"> <affine-paragraph id="paragraph-1">Hel111</affine-paragraph> <affine-code id="code"></affine-code> <affine-paragraph id="paragraph-2">222ld</affine-paragraph> </affine-note> </affine-page> `; expect(host.doc).toEqualDoc(expected.doc); ``` 3. Added support for text cursors in unit test template syntax. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit > CLOSE BS-3278 - **New Features** - Introduced the ability to replace selected text in documents with blocks, supporting advanced merging and insertion scenarios for various block types. - **Bug Fixes** - Improved handling of text selection and cursor placement in document templates used for testing. - **Tests** - Added comprehensive tests for replacing selected text with blocks, including edge cases and complex selection scenarios. - Enhanced test utilities for document structure comparison and selection handling. - Updated end-to-end tests to verify correct replacement of selected text via AI-driven actions. - **Chores** - Added and updated dependencies to support new features. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -13,6 +13,7 @@ export {
|
||||
draftSelectedModelsCommand,
|
||||
duplicateSelectedModelsCommand,
|
||||
getSelectedModelsCommand,
|
||||
replaceSelectedTextWithBlocksCommand,
|
||||
retainFirstModelCommand,
|
||||
} from './model-crud/index.js';
|
||||
export {
|
||||
|
||||
@@ -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';
|
||||
|
||||
+399
@@ -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();
|
||||
};
|
||||
Reference in New Issue
Block a user