diff --git a/blocksuite/affine/shared/package.json b/blocksuite/affine/shared/package.json index cc11b5219b..7cb12e66c7 100644 --- a/blocksuite/affine/shared/package.json +++ b/blocksuite/affine/shared/package.json @@ -44,6 +44,7 @@ "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "rxjs": "^7.8.1", + "ts-pattern": "^5.1.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "yjs": "^13.6.21", diff --git a/blocksuite/affine/shared/src/__tests__/commands/model-crud/replace-selected-text-with-blocks.unit.spec.ts b/blocksuite/affine/shared/src/__tests__/commands/model-crud/replace-selected-text-with-blocks.unit.spec.ts new file mode 100644 index 0000000000..8a286d38ca --- /dev/null +++ b/blocksuite/affine/shared/src/__tests__/commands/model-crud/replace-selected-text-with-blocks.unit.spec.ts @@ -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` + + + Hello + World + + + `; + + const blocks = [ + block`111`, + block``, + block`222`, + ] + .filter((b): b is NonNullable => b !== null) + .map(b => b.model); + + const textSelection = host.selection.value[0] as TextSelection; + + host.command.exec(replaceSelectedTextWithBlocksCommand, { + textSelection, + blocks, + }); + + const expected = affine` + + + Hel111 + + 222ld + + + `; + + 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` + + + Hello World + + + `; + + const blocks = [ + block`111`, + block``, + block`222`, + ] + .filter((b): b is NonNullable => b !== null) + .map(b => b.model); + + const textSelection = host.selection.value[0] as TextSelection; + + host.command.exec(replaceSelectedTextWithBlocksCommand, { + textSelection, + blocks, + }); + + const expected = affine` + + + Hel111 + + 222ld + + + `; + + expect(host.store).toEqualDoc(expected.store); + }); + + it('should replace selected text with blocks when blocks contains only one mergable block', () => { + const host = affine` + + + Hello + World + + + `; + + const blocks = [block`111`] + .filter((b): b is NonNullable => b !== null) + .map(b => b.model); + + const textSelection = host.selection.value[0] as TextSelection; + + host.command.exec(replaceSelectedTextWithBlocksCommand, { + textSelection, + blocks, + }); + + const expected = affine` + + + Hel111ld + + + `; + + 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` + + + Hello World + + + `; + + const blocks = [block`111`] + .filter((b): b is NonNullable => b !== null) + .map(b => b.model); + + const textSelection = host.selection.value[0] as TextSelection; + + host.command.exec(replaceSelectedTextWithBlocksCommand, { + textSelection, + blocks, + }); + + const expected = affine` + + + Hel111ld + + + `; + + expect(host.store).toEqualDoc(expected.store); + }); + + it('should replace selected text with blocks when only first block is mergable block', () => { + const host = affine` + + + Hello + World + + + `; + + const blocks = [ + block`111`, + block``, + block``, + ] + .filter((b): b is NonNullable => b !== null) + .map(b => b.model); + + const textSelection = host.selection.value[0] as TextSelection; + + host.command.exec(replaceSelectedTextWithBlocksCommand, { + textSelection, + blocks, + }); + + const expected = affine` + + + Hel111 + + + ld + + + `; + + 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` + + + Hello World + + + `; + + const blocks = [ + block`111`, + block``, + block``, + ] + .filter((b): b is NonNullable => b !== null) + .map(b => b.model); + + const textSelection = host.selection.value[0] as TextSelection; + + host.command.exec(replaceSelectedTextWithBlocksCommand, { + textSelection, + blocks, + }); + + const expected = affine` + + + Hel111 + + + ld + + + `; + + expect(host.store).toEqualDoc(expected.store); + }); + + it('should replace selected text with blocks when only last block is mergable block', () => { + const host = affine` + + + Hello + World + + + `; + + const blocks = [ + block``, + block``, + block`111`, + ] + .filter((b): b is NonNullable => b !== null) + .map(b => b.model); + + const textSelection = host.selection.value[0] as TextSelection; + + host.command.exec(replaceSelectedTextWithBlocksCommand, { + textSelection, + blocks, + }); + + const expected = affine` + + + Hel + + + 111ld + + + `; + 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` + + + Hello World + + + `; + + const blocks = [ + block``, + block``, + block`111`, + ] + .filter((b): b is NonNullable => b !== null) + .map(b => b.model); + + const textSelection = host.selection.value[0] as TextSelection; + + host.command.exec(replaceSelectedTextWithBlocksCommand, { + textSelection, + blocks, + }); + + const expected = affine` + + + Hel + + + 111ld + + + `; + 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` + + + Hello + World + + + `; + + const blocks = [ + block``, + block``, + ] + .filter((b): b is NonNullable => b !== null) + .map(b => b.model); + + const textSelection = host.selection.value[0] as TextSelection; + + host.command.exec(replaceSelectedTextWithBlocksCommand, { + textSelection, + blocks, + }); + + const expected = affine` + + + Hel + + + ld + + + `; + 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` + + + Hello World + + + `; + + const blocks = [ + block``, + block``, + ] + .filter((b): b is NonNullable => b !== null) + .map(b => b.model); + + const textSelection = host.selection.value[0] as TextSelection; + + host.command.exec(replaceSelectedTextWithBlocksCommand, { + textSelection, + blocks, + }); + + const expected = affine` + + + Hel + + + ld + + + `; + 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` + + + Hello + World + + + `; + + const blocks = [ + block`1.`, + block`2.`, + ] + .filter((b): b is NonNullable => b !== null) + .map(b => b.model); + + const textSelection = host.selection.value[0] as TextSelection; + + host.command.exec(replaceSelectedTextWithBlocksCommand, { + textSelection, + blocks, + }); + + const expected = affine` + + + Hel + 1. + 2. + ld + + + `; + 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` + + + Hello + World + + + `; + + const blocks = [ + block`111`, + block`222`, + ] + .filter((b): b is NonNullable => b !== null) + .map(b => b.model); + + const textSelection = host.selection.value[0] as TextSelection; + + host.command.exec(replaceSelectedTextWithBlocksCommand, { + textSelection, + blocks, + }); + + const expected = affine` + + + Hel111 + 222ld + + + `; + 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` + + + Hello + World + + + `; + + const blocks = [ + block`111`, + block``, + ] + .filter((b): b is NonNullable => b !== null) + .map(b => b.model); + + const textSelection = host.selection.value[0] as TextSelection; + + host.command.exec(replaceSelectedTextWithBlocksCommand, { + textSelection, + blocks, + }); + + const expected = affine` + + + Hel111 + + ld + + + `; + 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` + + + Hello + World + + + `; + + const blocks = [ + block``, + block`222`, + ] + .filter((b): b is NonNullable => b !== null) + .map(b => b.model); + + const textSelection = host.selection.value[0] as TextSelection; + + host.command.exec(replaceSelectedTextWithBlocksCommand, { + textSelection, + blocks, + }); + + const expected = affine` + + + Hel + + 222ld + + + `; + expect(host.store).toEqualDoc(expected.store); + }); + }); +}); diff --git a/blocksuite/affine/shared/src/__tests__/helpers/affine-template.ts b/blocksuite/affine/shared/src/__tests__/helpers/affine-template.ts index dac2692784..b3ce806181 100644 --- a/blocksuite/affine/shared/src/__tests__/helpers/affine-template.ts +++ b/blocksuite/affine/shared/src/__tests__/helpers/affine-template.ts @@ -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 = { '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 = { * const host = affine` * * - * Hello, world + * Hello, world + * Hello, world * * * `; @@ -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; diff --git a/blocksuite/affine/shared/src/__tests__/helpers/affine-template.unit.spec.ts b/blocksuite/affine/shared/src/__tests__/helpers/affine-template.unit.spec.ts index 0fbaf1aab5..843a07c11a 100644 --- a/blocksuite/affine/shared/src/__tests__/helpers/affine-template.unit.spec.ts +++ b/blocksuite/affine/shared/src/__tests__/helpers/affine-template.unit.spec.ts @@ -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` + + + Hello + World + + + `; + + 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` + + + HelloWorld + + + `; + + 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` + + + + + + `; + + 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` + + + HelloWorldAffine + + + `; + + 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(); + }); }); diff --git a/blocksuite/affine/shared/src/__tests__/helpers/affine-test-utils.ts b/blocksuite/affine/shared/src/__tests__/helpers/affine-test-utils.ts new file mode 100644 index 0000000000..ee27054de6 --- /dev/null +++ b/blocksuite/affine/shared/src/__tests__/helpers/affine-test-utils.ts @@ -0,0 +1,115 @@ +import type { BlockModel, Store } from '@blocksuite/store'; +import { expect } from 'vitest'; + +declare module 'vitest' { + interface Assertion { + 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}` + : children + ? `>\n${children}\n${indent}` + : `>`; + + 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, + }; + } + }, +}); diff --git a/blocksuite/affine/shared/src/__tests__/helpers/create-test-host.ts b/blocksuite/affine/shared/src/__tests__/helpers/create-test-host.ts index 31c3b74343..4861723dea 100644 --- a/blocksuite/affine/shared/src/__tests__/helpers/create-test-host.ts +++ b/blocksuite/affine/shared/src/__tests__/helpers/create-test-host.ts @@ -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; } diff --git a/blocksuite/affine/shared/src/commands/index.ts b/blocksuite/affine/shared/src/commands/index.ts index 00cd284aba..b05b0bb35f 100644 --- a/blocksuite/affine/shared/src/commands/index.ts +++ b/blocksuite/affine/shared/src/commands/index.ts @@ -13,6 +13,7 @@ export { draftSelectedModelsCommand, duplicateSelectedModelsCommand, getSelectedModelsCommand, + replaceSelectedTextWithBlocksCommand, retainFirstModelCommand, } from './model-crud/index.js'; export { diff --git a/blocksuite/affine/shared/src/commands/model-crud/index.ts b/blocksuite/affine/shared/src/commands/model-crud/index.ts index 85fe888958..a7c99fb925 100644 --- a/blocksuite/affine/shared/src/commands/model-crud/index.ts +++ b/blocksuite/affine/shared/src/commands/model-crud/index.ts @@ -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'; diff --git a/blocksuite/affine/shared/src/commands/model-crud/replace-selected-text-with-blocks.ts b/blocksuite/affine/shared/src/commands/model-crud/replace-selected-text-with-blocks.ts new file mode 100644 index 0000000000..e03facce63 --- /dev/null +++ b/blocksuite/affine/shared/src/commands/model-crud/replace-selected-text-with-blocks.ts @@ -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 = ( + * + * Hello + * World + * + * ); + * + * const snapshot = [ + * 111, + * ]; + * + * const expected = ( + * + * Hel111ld + * + * ); + * ``` + */ + 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 = ( + * + * Hello + * World + * + * ); + * + * const snapshot = [ + * 111, + * + * + * 222, + * ]; + * + * const expected = ( + * + * Hel111 + * + * + * 222ld + * + * ); + */ + 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 = ( + * + * Hello + * World + * + * ); + * + * const snapshot = [ + * 111, + * + * + * ]; + * + * const expected = ( + * + * Hel111 + * + * + * ld + * + * ); + * ``` + */ + 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 = ( + * + * Hello + * World + * + * ); + * + * const snapshot = [ + * + * + * 222 + * ]; + * + * const expected = ( + * + * Hel + * + * + * 222ld + * + * ); + * ``` + */ + 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 = ( + * + * Hello + * World + * + * ); + * + * const snapshot = [ + * + * + * ]; + * + * const expected = ( + * + * Hel + * + * + * ld + * + * ); + * ``` + */ + splitParagraph(doc, parent, startBlockModel, startIndex, startOffset); + addBlocks(doc, blocks, parent, startIndex + 1); + }); + return next(); +}; diff --git a/packages/frontend/core/src/blocksuite/ai/utils/editor-actions.ts b/packages/frontend/core/src/blocksuite/ai/utils/editor-actions.ts index 1d7c9707d8..dddeb56177 100644 --- a/packages/frontend/core/src/blocksuite/ai/utils/editor-actions.ts +++ b/packages/frontend/core/src/blocksuite/ai/utils/editor-actions.ts @@ -1,5 +1,6 @@ -import { deleteTextCommand } from '@blocksuite/affine/inlines/preset'; +import { WorkspaceImpl } from '@affine/core/modules/workspace/impls/workspace'; import { defaultImageProxyMiddleware } from '@blocksuite/affine/shared/adapters'; +import { replaceSelectedTextWithBlocksCommand } from '@blocksuite/affine/shared/commands'; import { isInsideEdgelessEditor } from '@blocksuite/affine/shared/utils'; import { type BlockComponent, @@ -8,7 +9,12 @@ import { SurfaceSelection, type TextSelection, } from '@blocksuite/affine/std'; -import { type BlockModel, Slice } from '@blocksuite/affine/store'; +import { + type BlockModel, + type BlockSnapshot, + Slice, +} from '@blocksuite/affine/store'; +import { Doc as YDoc } from 'yjs'; import { insertFromMarkdown, @@ -109,19 +115,53 @@ export const replace = async ( ); if (textSelection) { - host.std.command.exec(deleteTextCommand, { textSelection }); - const { snapshot, transformer } = await markdownToSnapshot( - content, - host.store, - host - ); - if (snapshot) { - await transformer.snapshotToSlice( - snapshot, - host.store, - firstBlockParent.model.id, - firstIndex + 1 + const collection = new WorkspaceImpl({ + id: 'AI_REPLACE', + rootDoc: new YDoc({ guid: 'AI_REPLACE' }), + }); + collection.meta.initialize(); + const fragmentDoc = collection.createDoc(); + + try { + const fragment = fragmentDoc.getStore(); + fragmentDoc.load(); + + const rootId = fragment.addBlock('affine:page'); + fragment.addBlock('affine:surface', {}, rootId); + const noteId = fragment.addBlock('affine:note', {}, rootId); + + const { snapshot, transformer } = await markdownToSnapshot( + content, + fragment, + host ); + + if (snapshot) { + const blockSnapshots = ( + snapshot.content[0].flavour === 'affine:note' + ? snapshot.content[0].children + : snapshot.content + ) as BlockSnapshot[]; + + const blocks = ( + await Promise.all( + blockSnapshots.map(async blockSnapshot => { + return await transformer.snapshotToBlock( + blockSnapshot, + fragment, + noteId, + 0 + ); + }) + ) + ).filter(block => block) as BlockModel[]; + host.std.command.exec(replaceSelectedTextWithBlocksCommand, { + textSelection, + blocks, + }); + } + } finally { + collection.dispose(); } } else { selectedModels.forEach(model => { diff --git a/tests/affine-cloud-copilot/e2e/chat-with/text.spec.ts b/tests/affine-cloud-copilot/e2e/chat-with/text.spec.ts index 827463987a..e261323063 100644 --- a/tests/affine-cloud-copilot/e2e/chat-with/text.spec.ts +++ b/tests/affine-cloud-copilot/e2e/chat-with/text.spec.ts @@ -1,3 +1,4 @@ +import { IS_MAC } from '@blocksuite/global/env'; import { expect } from '@playwright/test'; import { test } from '../base/base-test'; @@ -62,13 +63,22 @@ test.describe('AIChatWith/Text', () => { loggedInPage: page, utils, }) => { - const { translate } = await utils.editor.askAIWithText(page, 'Apple'); - const { answer } = await translate('German'); - await expect(answer).toHaveText(/Apfel/, { timeout: 10000 }); + await utils.editor.focusToEditor(page); + await page.keyboard.insertText('I Loev Apple'); + + // 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'); await replace.click(); const content = await utils.editor.getEditorContent(page); - expect(content).toBe('Apfel'); + expect(content).toBe('I Love Apple'); }); test('should support continue in chat', async ({ diff --git a/tests/affine-cloud-copilot/e2e/utils/editor-utils.ts b/tests/affine-cloud-copilot/e2e/utils/editor-utils.ts index 7d07b3e8cc..adaf150dac 100644 --- a/tests/affine-cloud-copilot/e2e/utils/editor-utils.ts +++ b/tests/affine-cloud-copilot/e2e/utils/editor-utils.ts @@ -546,19 +546,7 @@ export class EditorUtils { }; } - 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'); - + public static async showAIMenu(page: Page) { const askAI = await page.locator('page-editor editor-toolbar ask-ai-icon'); await askAI.waitFor({ state: 'attached', @@ -661,6 +649,22 @@ export class EditorUtils { } 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) { const textarea = page.locator( 'affine-ai-panel-widget .ai-panel-container textarea' diff --git a/yarn.lock b/yarn.lock index 89d10cd677..36894d9c14 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3714,6 +3714,7 @@ __metadata: remark-parse: "npm:^11.0.0" remark-stringify: "npm:^11.0.0" rxjs: "npm:^7.8.1" + ts-pattern: "npm:^5.1.0" unified: "npm:^11.0.5" unist-util-visit: "npm:^5.0.0" vitest: "npm:3.1.3" @@ -33045,6 +33046,13 @@ __metadata: languageName: node 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": version: 4.2.0 resolution: "tsconfig-paths@npm:4.2.0"