diff --git a/blocksuite/affine/shared/package.json b/blocksuite/affine/shared/package.json index ddbf3305a1..dd8a2cecfe 100644 --- a/blocksuite/affine/shared/package.json +++ b/blocksuite/affine/shared/package.json @@ -10,6 +10,8 @@ "author": "toeverything", "license": "MIT", "dependencies": { + "@blocksuite/affine": "workspace:*", + "@blocksuite/affine-ext-loader": "workspace:*", "@blocksuite/affine-model": "workspace:*", "@blocksuite/global": "workspace:*", "@blocksuite/icons": "^2.2.12", @@ -63,7 +65,8 @@ "./theme": "./src/theme/index.ts", "./styles": "./src/styles/index.ts", "./services": "./src/services/index.ts", - "./adapters": "./src/adapters/index.ts" + "./adapters": "./src/adapters/index.ts", + "./test-utils": "./src/test-utils/index.ts" }, "files": [ "src", diff --git a/blocksuite/affine/shared/src/__tests__/commands/block-crud/get-first-block.unit.spec.ts b/blocksuite/affine/shared/src/__tests__/commands/block-crud/get-first-block.unit.spec.ts index 396c43f971..18be608999 100644 --- a/blocksuite/affine/shared/src/__tests__/commands/block-crud/get-first-block.unit.spec.ts +++ b/blocksuite/affine/shared/src/__tests__/commands/block-crud/get-first-block.unit.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'; import { getFirstBlockCommand } from '../../../commands/block-crud/get-first-content-block'; -import { affine } from '../../helpers/affine-template'; +import { affine } from '../../../test-utils'; describe('commands/block-crud', () => { describe('getFirstBlockCommand', () => { diff --git a/blocksuite/affine/shared/src/__tests__/commands/block-crud/get-last-block.unit.spec.ts b/blocksuite/affine/shared/src/__tests__/commands/block-crud/get-last-block.unit.spec.ts index af8b906fcb..34fdd8deb2 100644 --- a/blocksuite/affine/shared/src/__tests__/commands/block-crud/get-last-block.unit.spec.ts +++ b/blocksuite/affine/shared/src/__tests__/commands/block-crud/get-last-block.unit.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'; import { getLastBlockCommand } from '../../../commands/block-crud/get-last-content-block'; -import { affine } from '../../helpers/affine-template'; +import { affine } from '../../../test-utils'; describe('commands/block-crud', () => { describe('getLastBlockCommand', () => { 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 index 8a286d38ca..1ffe119536 100644 --- 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 @@ -1,13 +1,13 @@ /** * @vitest-environment happy-dom */ -import '../../helpers/affine-test-utils'; +import '../../../test-utils/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'; +import { affine, block } from '../../../test-utils'; describe('commands/model-crud', () => { describe('replaceSelectedTextWithBlocksCommand', () => { diff --git a/blocksuite/affine/shared/src/__tests__/commands/selection/is-nothing-selected.unit.spec.ts b/blocksuite/affine/shared/src/__tests__/commands/selection/is-nothing-selected.unit.spec.ts index c374a90e5b..610c8a4239 100644 --- a/blocksuite/affine/shared/src/__tests__/commands/selection/is-nothing-selected.unit.spec.ts +++ b/blocksuite/affine/shared/src/__tests__/commands/selection/is-nothing-selected.unit.spec.ts @@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest'; import { isNothingSelectedCommand } from '../../../commands/selection/is-nothing-selected'; import { ImageSelection } from '../../../selection'; -import { affine } from '../../helpers/affine-template'; +import { affine } from '../../../test-utils'; describe('commands/selection', () => { describe('isNothingSelectedCommand', () => { diff --git a/blocksuite/affine/shared/src/__tests__/helpers/affine-template.unit.spec.ts b/blocksuite/affine/shared/src/__tests__/test-utils/affine-template.unit.spec.ts similarity index 99% rename from blocksuite/affine/shared/src/__tests__/helpers/affine-template.unit.spec.ts rename to blocksuite/affine/shared/src/__tests__/test-utils/affine-template.unit.spec.ts index 843a07c11a..39fb5a12a7 100644 --- a/blocksuite/affine/shared/src/__tests__/helpers/affine-template.unit.spec.ts +++ b/blocksuite/affine/shared/src/__tests__/test-utils/affine-template.unit.spec.ts @@ -1,7 +1,7 @@ import { TextSelection } from '@blocksuite/std'; import { describe, expect, it } from 'vitest'; -import { affine } from './affine-template'; +import { affine } from '../../test-utils'; describe('helpers/affine-template', () => { it('should create a basic document structure from template', () => { diff --git a/blocksuite/affine/shared/src/__tests__/helpers/README.md b/blocksuite/affine/shared/src/test-utils/README.md similarity index 100% rename from blocksuite/affine/shared/src/__tests__/helpers/README.md rename to blocksuite/affine/shared/src/test-utils/README.md diff --git a/blocksuite/affine/shared/src/__tests__/helpers/affine-template.ts b/blocksuite/affine/shared/src/test-utils/affine-template.ts similarity index 88% rename from blocksuite/affine/shared/src/__tests__/helpers/affine-template.ts rename to blocksuite/affine/shared/src/test-utils/affine-template.ts index b3ce806181..393bf0e4e7 100644 --- a/blocksuite/affine/shared/src/__tests__/helpers/affine-template.ts +++ b/blocksuite/affine/shared/src/test-utils/affine-template.ts @@ -1,29 +1,32 @@ -import { - CodeBlockSchemaExtension, - DatabaseBlockSchemaExtension, - ImageBlockSchemaExtension, - ListBlockSchemaExtension, - NoteBlockSchemaExtension, - ParagraphBlockSchemaExtension, - RootBlockSchemaExtension, -} from '@blocksuite/affine-model'; +import { getInternalStoreExtensions } from '@blocksuite/affine/extensions/store'; +import { StoreExtensionManager } from '@blocksuite/affine-ext-loader'; +import { Container } from '@blocksuite/global/di'; import { TextSelection } from '@blocksuite/std'; -import { type Block, type Store } from '@blocksuite/store'; -import { Text } from '@blocksuite/store'; +import { type Block, type Store, Text } from '@blocksuite/store'; import { TestWorkspace } from '@blocksuite/store/test'; import { createTestHost } from './create-test-host'; -// Extensions array -const extensions = [ - RootBlockSchemaExtension, - NoteBlockSchemaExtension, - ParagraphBlockSchemaExtension, - ListBlockSchemaExtension, - ImageBlockSchemaExtension, - DatabaseBlockSchemaExtension, - CodeBlockSchemaExtension, -]; +const manager = new StoreExtensionManager(getInternalStoreExtensions()); +const extensions = manager.get('store'); + +// // Extensions array +// const extensions = [ +// RootBlockSchemaExtension, +// NoteBlockSchemaExtension, +// ParagraphBlockSchemaExtension, +// ListBlockSchemaExtension, +// ImageBlockSchemaExtension, +// DatabaseBlockSchemaExtension, +// CodeBlockSchemaExtension, +// RootStoreExtension, +// NoteStoreExtension, +// ParagraphStoreExtension, +// ListStoreExtension, +// ImageStoreExtension, +// DatabaseStoreExtension, +// CodeStoreExtension +// ]; // Mapping from tag names to flavours const tagToFlavour: Record = { @@ -75,8 +78,11 @@ export function affine(strings: TemplateStringsArray, ...values: any[]) { const workspace = new TestWorkspace({}); workspace.meta.initialize(); const doc = workspace.createDoc('test-doc'); - const store = doc.getStore({ extensions }); - + const container = new Container(); + extensions.forEach(extension => { + extension.setup(container); + }); + const store = doc.getStore({ extensions, provider: container.provider() }); let selectionInfo: SelectionInfo = {}; // Use DOMParser to parse HTML string diff --git a/blocksuite/affine/shared/src/__tests__/helpers/affine-test-utils.ts b/blocksuite/affine/shared/src/test-utils/affine-test-utils.ts similarity index 93% rename from blocksuite/affine/shared/src/__tests__/helpers/affine-test-utils.ts rename to blocksuite/affine/shared/src/test-utils/affine-test-utils.ts index ee27054de6..5ba700a82f 100644 --- a/blocksuite/affine/shared/src/__tests__/helpers/affine-test-utils.ts +++ b/blocksuite/affine/shared/src/test-utils/affine-test-utils.ts @@ -63,10 +63,8 @@ function compareBlocks( 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; + for (const [i, child] of actual.children.entries()) { + if (!compareBlocks(child, expected.children[i], compareId)) return false; } return true; diff --git a/blocksuite/affine/shared/src/__tests__/helpers/create-test-host.ts b/blocksuite/affine/shared/src/test-utils/create-test-host.ts similarity index 99% rename from blocksuite/affine/shared/src/__tests__/helpers/create-test-host.ts rename to blocksuite/affine/shared/src/test-utils/create-test-host.ts index 4861723dea..d35f754fee 100644 --- a/blocksuite/affine/shared/src/__tests__/helpers/create-test-host.ts +++ b/blocksuite/affine/shared/src/test-utils/create-test-host.ts @@ -240,7 +240,7 @@ export function createTestHost(doc: Store): EditorHost { std.selection = new MockSelectionStore(); std.command = new CommandManager(std as any); - // @ts-expect-error + // @ts-expect-error dev-only host.command = std.command; host.selection = std.selection; diff --git a/blocksuite/affine/shared/src/test-utils/index.ts b/blocksuite/affine/shared/src/test-utils/index.ts new file mode 100644 index 0000000000..9f5cd5bb48 --- /dev/null +++ b/blocksuite/affine/shared/src/test-utils/index.ts @@ -0,0 +1,3 @@ +export * from './affine-template'; +export * from './affine-test-utils'; +export * from './create-test-host'; diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index 54c200b5a6..efd8b64c1a 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -19,6 +19,7 @@ "@affine/templates": "workspace:*", "@affine/track": "workspace:*", "@blocksuite/affine": "workspace:*", + "@blocksuite/affine-shared": "workspace:*", "@blocksuite/icons": "^2.2.13", "@blocksuite/std": "workspace:*", "@dotlottie/player-component": "^2.7.12", diff --git a/packages/frontend/core/src/__tests__/ai/utils/apply-model/apply-patch-to-doc.spec.ts b/packages/frontend/core/src/__tests__/ai/utils/apply-model/apply-patch-to-doc.spec.ts new file mode 100644 index 0000000000..e730707839 --- /dev/null +++ b/packages/frontend/core/src/__tests__/ai/utils/apply-model/apply-patch-to-doc.spec.ts @@ -0,0 +1,99 @@ +/** + * @vitest-environment happy-dom + */ +import { affine } from '@blocksuite/affine-shared/test-utils'; +import { describe, expect, it } from 'vitest'; + +import { applyPatchToDoc } from '../../../../blocksuite/ai/utils/apply-model/apply-patch-to-doc'; +import type { PatchOp } from '../../../../blocksuite/ai/utils/apply-model/markdown-diff'; + +describe('applyPatchToDoc', () => { + it('should delete a block', async () => { + const host = affine` + + + Hello + World + + + `; + + const patch: PatchOp[] = [{ op: 'delete', block_id: 'paragraph-1' }]; + await applyPatchToDoc(host.store, patch); + + const expected = affine` + + + World + + + `; + + expect(host.store).toEqualDoc(expected.store); + }); + + it('should replace a block', async () => { + const host = affine` + + + Hello + World + + + `; + + const patch: PatchOp[] = [ + { + op: 'replace', + block_id: 'paragraph-1', + new_content: 'New content', + }, + ]; + + await applyPatchToDoc(host.store, patch); + + const expected = affine` + + + New content + World + + + `; + + expect(host.store).toEqualDoc(expected.store); + }); + + it('should insert a block at index', async () => { + const host = affine` + + + Hello + World + + + `; + + const patch: PatchOp[] = [ + { + op: 'insert_at', + index: 2, + new_block: { type: 'affine:paragraph', content: 'Inserted' }, + }, + ]; + + await applyPatchToDoc(host.store, patch); + + const expected = affine` + + + Hello + World + Inserted + + + `; + + expect(host.store).toEqualDoc(expected.store); + }); +}); diff --git a/packages/frontend/core/src/__tests__/ai/utils/apply-model/markdown-diff.spec.ts b/packages/frontend/core/src/__tests__/ai/utils/apply-model/markdown-diff.spec.ts new file mode 100644 index 0000000000..ec7d40a201 --- /dev/null +++ b/packages/frontend/core/src/__tests__/ai/utils/apply-model/markdown-diff.spec.ts @@ -0,0 +1,223 @@ +import { describe, expect, test } from 'vitest'; + +import { diffMarkdown } from '../../../../blocksuite/ai/utils/apply-model/markdown-diff'; + +describe('diffMarkdown', () => { + test('should diff block insertion', () => { + // Only a new block is inserted + const oldMd = ` + +# Title +`; + const newMd = ` + +# Title + + +This is a new paragraph. +`; + const patch = diffMarkdown(oldMd, newMd); + expect(patch).toEqual([ + { + op: 'insert_at', + index: 1, + new_block: { + type: 'paragraph', + content: 'This is a new paragraph.', + }, + }, + ]); + }); + + test('should diff block deletion', () => { + // A block is deleted + const oldMd = ` + +# Title + + +This paragraph will be deleted. +`; + const newMd = ` + +# Title +`; + const patch = diffMarkdown(oldMd, newMd); + expect(patch).toEqual([ + { + op: 'delete', + block_id: 'block-002', + }, + ]); + }); + + test('should diff block replacement', () => { + // Only content of a block is changed + const oldMd = ` + +# Old Title +`; + const newMd = ` + +# New Title +`; + const patch = diffMarkdown(oldMd, newMd); + expect(patch).toEqual([ + { + op: 'replace', + block_id: 'block-001', + new_content: '# New Title', + }, + ]); + }); + + test('should diff mixed changes', () => { + // Mixed: delete, insert, replace + const oldMd = ` + +# Title + + +Old paragraph. + + +To be deleted. +`; + const newMd = ` + +# Title + + +Updated paragraph. + + +New paragraph. +`; + const patch = diffMarkdown(oldMd, newMd); + expect(patch).toEqual([ + { + op: 'replace', + block_id: 'block-002', + new_content: 'Updated paragraph.', + }, + { + op: 'insert_at', + index: 2, + new_block: { + type: 'paragraph', + content: 'New paragraph.', + }, + }, + { + op: 'delete', + block_id: 'block-003', + }, + ]); + }); + + test('should diff consecutive block insertions', () => { + // Two new blocks are inserted consecutively + const oldMd = ` + +# Title +`; + const newMd = ` + +# Title + + +First inserted paragraph. + + +Second inserted paragraph. +`; + const patch = diffMarkdown(oldMd, newMd); + expect(patch).toEqual([ + { + op: 'insert_at', + index: 1, + new_block: { + type: 'paragraph', + content: 'First inserted paragraph.', + }, + }, + { + op: 'insert_at', + index: 2, + new_block: { + type: 'paragraph', + content: 'Second inserted paragraph.', + }, + }, + ]); + }); + + test('should diff consecutive block deletions', () => { + // Two blocks are deleted consecutively + const oldMd = ` + +# Title + + +First paragraph to be deleted. + + +Second paragraph to be deleted. +`; + const newMd = ` + +# Title +`; + const patch = diffMarkdown(oldMd, newMd); + expect(patch).toEqual([ + { + op: 'delete', + block_id: 'block-002', + }, + { + op: 'delete', + block_id: 'block-003', + }, + ]); + }); + + test('should diff deletion followed by insertion at the same position', () => { + // A block is deleted and a new block is inserted at the end + const oldMd = ` + +# Title + + +This paragraph will be deleted + + +HelloWorld +`; + + const newMd = ` + +# Title + + +HelloWorld + + +This is a new paragraph inserted after deletion. +`; + const patch = diffMarkdown(oldMd, newMd); + expect(patch).toEqual([ + { + op: 'insert_at', + index: 2, + new_block: { + type: 'paragraph', + content: 'This is a new paragraph inserted after deletion.', + }, + }, + { + op: 'delete', + block_id: 'block-002', + }, + ]); + }); +}); diff --git a/packages/frontend/core/src/blocksuite/ai/utils/apply-model/apply-patch-to-doc.ts b/packages/frontend/core/src/blocksuite/ai/utils/apply-model/apply-patch-to-doc.ts new file mode 100644 index 0000000000..114e89ff2c --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/utils/apply-model/apply-patch-to-doc.ts @@ -0,0 +1,54 @@ +import type { Store } from '@blocksuite/store'; + +import { insertFromMarkdown } from '../../../utils'; +import type { PatchOp } from './markdown-diff'; + +/** + * Apply a list of PatchOp to the page doc (children of the first note block) + * @param doc The page document Store + * @param patch Array of PatchOp + */ +export async function applyPatchToDoc( + doc: Store, + patch: PatchOp[] +): Promise { + // Get all note blocks + const notes = doc.getBlocksByFlavour('affine:note'); + if (notes.length === 0) return; + // Only handle the first note block + const note = notes[0].model; + + // Build a map from block_id to BlockModel for quick lookup + const blockIdMap = new Map(); + note.children.forEach(child => { + blockIdMap.set(child.id, child); + }); + + for (const op of patch) { + if (op.op === 'delete') { + // Delete block + doc.deleteBlock(op.block_id); + } else if (op.op === 'replace') { + // Replace block: delete then insert + const oldBlock = blockIdMap.get(op.block_id); + if (!oldBlock) continue; + const parentId = note.id; + const index = note.children.findIndex(child => child.id === op.block_id); + if (index === -1) continue; + doc.deleteBlock(op.block_id); + // Insert new content + await insertFromMarkdown(undefined, op.new_content, doc, parentId, index); + } else if (op.op === 'insert_at') { + // Insert new block + const parentId = note.id; + const index = op.index; + await insertFromMarkdown( + undefined, + op.new_block.content, + doc, + parentId, + index + ); + } + } +} diff --git a/packages/frontend/core/src/blocksuite/ai/utils/apply-model/markdown-diff.ts b/packages/frontend/core/src/blocksuite/ai/utils/apply-model/markdown-diff.ts new file mode 100644 index 0000000000..c5a2c8dcac --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/utils/apply-model/markdown-diff.ts @@ -0,0 +1,111 @@ +export type Block = { + block_id: string; + type: string; + content: string; +}; + +type NewBlock = Omit; + +export type PatchOp = + | { op: 'replace'; block_id: string; new_content: string } + | { op: 'delete'; block_id: string } + | { op: 'insert_at'; index: number; new_block: NewBlock }; + +export function parseMarkdownToBlocks(markdown: string): Block[] { + const lines = markdown.split(/\r?\n/); + const blocks: Block[] = []; + let currentBlockId: string | null = null; + let currentType: string | null = null; + let currentContent: string[] = []; + + for (const line of lines) { + const match = line.match(/^/); + if (match) { + // If there is a block being collected, push it into blocks first + if (currentBlockId && currentType) { + blocks.push({ + block_id: currentBlockId, + type: currentType, + content: currentContent.join('\n').trim(), + }); + } + // Start a new block + currentBlockId = match[1]; + currentType = match[2]; + currentContent = []; + } else { + // Collect content + if (currentBlockId && currentType) { + currentContent.push(line); + } + } + } + // Collect the last block + if (currentBlockId && currentType) { + blocks.push({ + block_id: currentBlockId, + type: currentType, + content: currentContent.join('\n').trim(), + }); + } + return blocks; +} + +function diffBlockLists(oldBlocks: Block[], newBlocks: Block[]): PatchOp[] { + const patch: PatchOp[] = []; + const oldMap = new Map(); + oldBlocks.forEach((b, i) => oldMap.set(b.block_id, { block: b, index: i })); + const newMap = new Map(); + newBlocks.forEach((b, i) => newMap.set(b.block_id, { block: b, index: i })); + + // Mark old blocks that have been handled + const handledOld = new Set(); + + // First process newBlocks in order + newBlocks.forEach((newBlock, newIdx) => { + const old = oldMap.get(newBlock.block_id); + if (old) { + handledOld.add(newBlock.block_id); + if (old.block.content !== newBlock.content) { + patch.push({ + op: 'replace', + block_id: newBlock.block_id, + new_content: newBlock.content, + }); + } + } else { + patch.push({ + op: 'insert_at', + index: newIdx, + new_block: { + type: newBlock.type, + content: newBlock.content, + }, + }); + } + }); + + // Then process deleted oldBlocks + oldBlocks.forEach(oldBlock => { + if (!newMap.has(oldBlock.block_id)) { + patch.push({ + op: 'delete', + block_id: oldBlock.block_id, + }); + } + }); + + return patch; +} + +export function diffMarkdown( + oldMarkdown: string, + newMarkdown: string +): PatchOp[] { + const oldBlocks = parseMarkdownToBlocks(oldMarkdown); + const newBlocks = parseMarkdownToBlocks(newMarkdown); + + const patch: PatchOp[] = diffBlockLists(oldBlocks, newBlocks); + + return patch; +} diff --git a/yarn.lock b/yarn.lock index 9aff707fe3..e692a9875f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -399,6 +399,7 @@ __metadata: "@affine/templates": "workspace:*" "@affine/track": "workspace:*" "@blocksuite/affine": "workspace:*" + "@blocksuite/affine-shared": "workspace:*" "@blocksuite/icons": "npm:^2.2.13" "@blocksuite/std": "workspace:*" "@dotlottie/player-component": "npm:^2.7.12" @@ -3731,6 +3732,8 @@ __metadata: version: 0.0.0-use.local resolution: "@blocksuite/affine-shared@workspace:blocksuite/affine/shared" dependencies: + "@blocksuite/affine": "workspace:*" + "@blocksuite/affine-ext-loader": "workspace:*" "@blocksuite/affine-model": "workspace:*" "@blocksuite/global": "workspace:*" "@blocksuite/icons": "npm:^2.2.12"