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}${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,
+ };
+ }
+ },
+});
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"