diff --git a/blocksuite/affine/shared/package.json b/blocksuite/affine/shared/package.json index bf2bb700fe..8e75fe00a9 100644 --- a/blocksuite/affine/shared/package.json +++ b/blocksuite/affine/shared/package.json @@ -63,7 +63,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..c0d8f2129d 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,11 @@ /** * @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'; +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.ts b/blocksuite/affine/shared/src/__tests__/helpers/affine-template.ts deleted file mode 100644 index b3ce806181..0000000000 --- a/blocksuite/affine/shared/src/__tests__/helpers/affine-template.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { - CodeBlockSchemaExtension, - DatabaseBlockSchemaExtension, - ImageBlockSchemaExtension, - ListBlockSchemaExtension, - NoteBlockSchemaExtension, - 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'; - -import { createTestHost } from './create-test-host'; - -// Extensions array -const extensions = [ - RootBlockSchemaExtension, - NoteBlockSchemaExtension, - ParagraphBlockSchemaExtension, - ListBlockSchemaExtension, - ImageBlockSchemaExtension, - DatabaseBlockSchemaExtension, - CodeBlockSchemaExtension, -]; - -// Mapping from tag names to flavours -const tagToFlavour: Record = { - 'affine-page': 'affine:page', - 'affine-note': 'affine:note', - 'affine-paragraph': 'affine:paragraph', - '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 - * - * Example: - * ``` - * const host = affine` - * - * - * Hello, world - * Hello, world - * - * - * `; - * ``` - */ -export function affine(strings: TemplateStringsArray, ...values: any[]) { - // Merge template strings and values - let htmlString = ''; - strings.forEach((str, i) => { - htmlString += str; - if (i < values.length) { - htmlString += values[i]; - } - }); - - // Create a new doc - const workspace = new TestWorkspace({}); - workspace.meta.initialize(); - 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(); - const dom = parser.parseFromString(htmlString.trim(), 'text/html'); - const root = dom.body.firstElementChild; - - if (!root) { - throw new Error('Template must contain a root element'); - } - - buildDocFromElement(store, root, null, selectionInfo); - }); - - // 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; -} - -/** - * Create a single block from template string - * - * Example: - * ``` - * const block = block`` - * ``` - */ -export function block( - strings: TemplateStringsArray, - ...values: any[] -): Block | null { - // Merge template strings and values - let htmlString = ''; - strings.forEach((str, i) => { - htmlString += str; - if (i < values.length) { - htmlString += values[i]; - } - }); - - // Create a temporary doc to hold the block - const workspace = new TestWorkspace({}); - workspace.meta.initialize(); - const doc = workspace.createDoc('temp-doc'); - const store = doc.getStore({ extensions }); - - let blockId: string | null = null; - const selectionInfo: SelectionInfo = {}; - - // Use DOMParser to parse HTML string - doc.load(() => { - const parser = new DOMParser(); - const dom = parser.parseFromString(htmlString.trim(), 'text/html'); - const root = dom.body.firstElementChild; - - if (!root) { - throw new Error('Template must contain a root element'); - } - - // Create a root block if needed - const flavour = tagToFlavour[root.tagName.toLowerCase()]; - if ( - flavour === 'affine:paragraph' || - flavour === 'affine:list' || - flavour === 'affine:code' - ) { - const pageId = store.addBlock('affine:page', {}); - const noteId = store.addBlock('affine:note', {}, pageId); - blockId = buildDocFromElement(store, root, noteId, selectionInfo); - } else { - blockId = buildDocFromElement(store, root, null, selectionInfo); - } - }); - - // Return the created block - return blockId ? (store.getBlock(blockId) ?? null) : null; -} - -/** - * Recursively build document structure - * @param doc - * @param element - * @param parentId - * @param selectionInfo - * @returns - */ -function buildDocFromElement( - doc: Store, - element: Element, - 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) { - throw new Error(`Unknown tag name: ${tagName}`); - } - - const props: Record = {}; - - const customId = element.getAttribute('id'); - - // If ID is specified, add it to props - if (customId) { - props.id = customId; - } - - // Process element attributes - Array.from(element.attributes).forEach(attr => { - if (attr.name !== 'id') { - // Skip id attribute, we already handled it - props[attr.name] = attr.value; - } - }); - - // Special handling for different block types based on their flavours - switch (flavour) { - case 'affine:paragraph': - case 'affine:list': - if (element.textContent) { - props.text = new Text(element.textContent); - } - break; - } - - // Create block - const blockId = doc.addBlock(flavour, props, parentId); - - // Process all child nodes, including text nodes - Array.from(element.children).forEach(child => { - 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__/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 96% rename from blocksuite/affine/shared/src/__tests__/helpers/README.md rename to blocksuite/affine/shared/src/test-utils/README.md index 933058a0a4..7520fb621e 100644 --- a/blocksuite/affine/shared/src/__tests__/helpers/README.md +++ b/blocksuite/affine/shared/src/test-utils/README.md @@ -7,7 +7,7 @@ ### Basic Usage ```typescript -import { affine } from '../__tests__/utils/affine-template'; +import { affine } from '@blocksuite/affine-shared/test-utils'; // Create a simple document const doc = affine` diff --git a/blocksuite/affine/shared/src/test-utils/affine-template.ts b/blocksuite/affine/shared/src/test-utils/affine-template.ts new file mode 100644 index 0000000000..29b2c5b040 --- /dev/null +++ b/blocksuite/affine/shared/src/test-utils/affine-template.ts @@ -0,0 +1,316 @@ +import { + CodeBlockSchemaExtension, + DatabaseBlockSchemaExtension, + ImageBlockSchemaExtension, + ListBlockSchemaExtension, + NoteBlockSchemaExtension, + ParagraphBlockSchemaExtension, + RootBlockSchemaExtension, +} from '@blocksuite/affine-model'; +import { Container } from '@blocksuite/global/di'; +import { TextSelection } from '@blocksuite/std'; +import { + type Block, + type ExtensionType, + type Store, + Text, +} from '@blocksuite/store'; +import { TestWorkspace } from '@blocksuite/store/test'; + +import { createTestHost } from './create-test-host'; + +const DEFAULT_EXTENSIONS = [ + RootBlockSchemaExtension, + NoteBlockSchemaExtension, + ParagraphBlockSchemaExtension, + ListBlockSchemaExtension, + ImageBlockSchemaExtension, + DatabaseBlockSchemaExtension, + CodeBlockSchemaExtension, +]; + +// Mapping from tag names to flavours +const tagToFlavour: Record = { + 'affine-page': 'affine:page', + 'affine-note': 'affine:note', + 'affine-paragraph': 'affine:paragraph', + '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; +} + +export function createAffineTemplate( + extensions: ExtensionType[] = DEFAULT_EXTENSIONS +) { + /** + * Parse template strings and build BlockSuite document structure, + * then create a host object with the document + * + * Example: + * ``` + * const host = affine` + * + * + * Hello, world + * Hello, world + * + * + * `; + * ``` + */ + function affine(strings: TemplateStringsArray, ...values: any[]) { + // Merge template strings and values + let htmlString = ''; + strings.forEach((str, i) => { + htmlString += str; + if (i < values.length) { + htmlString += values[i]; + } + }); + + // Create a new doc + const workspace = new TestWorkspace({}); + workspace.meta.initialize(); + const doc = workspace.createDoc('test-doc'); + 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 + doc.load(() => { + const parser = new DOMParser(); + const dom = parser.parseFromString(htmlString.trim(), 'text/html'); + const root = dom.body.firstElementChild; + + if (!root) { + throw new Error('Template must contain a root element'); + } + + buildDocFromElement(store, root, null, selectionInfo); + }); + + // 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; + } + + /** + * Create a single block from template string + * + * Example: + * ``` + * const block = block`` + * ``` + */ + function block( + strings: TemplateStringsArray, + ...values: any[] + ): Block | null { + // Merge template strings and values + let htmlString = ''; + strings.forEach((str, i) => { + htmlString += str; + if (i < values.length) { + htmlString += values[i]; + } + }); + + // Create a temporary doc to hold the block + const workspace = new TestWorkspace({}); + workspace.meta.initialize(); + const doc = workspace.createDoc('temp-doc'); + const store = doc.getStore({ extensions }); + + let blockId: string | null = null; + const selectionInfo: SelectionInfo = {}; + + // Use DOMParser to parse HTML string + doc.load(() => { + const parser = new DOMParser(); + const dom = parser.parseFromString(htmlString.trim(), 'text/html'); + const root = dom.body.firstElementChild; + + if (!root) { + throw new Error('Template must contain a root element'); + } + + // Create a root block if needed + const flavour = tagToFlavour[root.tagName.toLowerCase()]; + if ( + flavour === 'affine:paragraph' || + flavour === 'affine:list' || + flavour === 'affine:code' + ) { + const pageId = store.addBlock('affine:page', {}); + const noteId = store.addBlock('affine:note', {}, pageId); + blockId = buildDocFromElement(store, root, noteId, selectionInfo); + } else { + blockId = buildDocFromElement(store, root, null, selectionInfo); + } + }); + + // Return the created block + return blockId ? (store.getBlock(blockId) ?? null) : null; + } + + return { + affine, + block, + }; +} + +export const { affine, block } = createAffineTemplate(); + +/** + * Recursively build document structure + * @param doc + * @param element + * @param parentId + * @param selectionInfo + * @returns + */ +function buildDocFromElement( + doc: Store, + element: Element, + 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) { + throw new Error(`Unknown tag name: ${tagName}`); + } + + const props: Record = {}; + + const customId = element.getAttribute('id'); + + // If ID is specified, add it to props + if (customId) { + props.id = customId; + } + + // Process element attributes + Array.from(element.attributes).forEach(attr => { + if (attr.name !== 'id') { + // Skip id attribute, we already handled it + props[attr.name] = attr.value; + } + }); + + // Special handling for different block types based on their flavours + switch (flavour) { + case 'affine:paragraph': + case 'affine:list': + if (element.textContent) { + props.text = new Text(element.textContent); + } + break; + } + + // Create block + const blockId = doc.addBlock(flavour, props, parentId); + + // Process all child nodes, including text nodes + Array.from(element.children).forEach(child => { + 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-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 cca018c5a1..e3fcb2b7bd 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", @@ -89,6 +90,7 @@ "zod": "^3.24.1" }, "devDependencies": { + "@blocksuite/affine-ext-loader": "workspace:*", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.1.0", "@types/animejs": "^3.1.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..1eb2ef30a7 --- /dev/null +++ b/packages/frontend/core/src/__tests__/ai/utils/apply-model/apply-patch-to-doc.spec.ts @@ -0,0 +1,114 @@ +/** + * @vitest-environment happy-dom + */ +import { getInternalStoreExtensions } from '@blocksuite/affine/extensions/store'; +import { StoreExtensionManager } from '@blocksuite/affine-ext-loader'; +import { createAffineTemplate } 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'; + +const manager = new StoreExtensionManager(getInternalStoreExtensions()); +const { affine } = createAffineTemplate(manager.get('store')); + +describe('applyPatchToDoc', () => { + it('should delete a block', async () => { + const host = affine` + + + Hello + World + + + `; + + const patch: PatchOp[] = [{ op: 'delete', id: 'paragraph-1' }]; + await applyPatchToDoc(host.store, patch); + + const expected = affine` + + + World + + + `; + + expect(host.store).toEqualDoc(expected.store, { + compareId: true, + }); + }); + + it('should replace a block', async () => { + const host = affine` + + + Hello + World + + + `; + + const patch: PatchOp[] = [ + { + op: 'replace', + id: 'paragraph-1', + content: 'New content', + }, + ]; + + await applyPatchToDoc(host.store, patch); + + const expected = affine` + + + New content + World + + + `; + + expect(host.store).toEqualDoc(expected.store, { + compareId: true, + }); + }); + + it('should insert a block at index', async () => { + const host = affine` + + + Hello + World + + + `; + + const patch: PatchOp[] = [ + { + op: 'insert', + index: 2, + block: { + id: 'paragraph-3', + type: 'affine:paragraph', + content: 'Inserted', + }, + }, + ]; + + await applyPatchToDoc(host.store, patch); + + const expected = affine` + + + Hello + World + Inserted + + + `; + + expect(host.store).toEqualDoc(expected.store, { + compareId: true, + }); + }); +}); diff --git a/packages/frontend/core/src/__tests__/ai/utils/apply-model/generate-render-diff.spec.ts b/packages/frontend/core/src/__tests__/ai/utils/apply-model/generate-render-diff.spec.ts new file mode 100644 index 0000000000..4fd489cdb6 --- /dev/null +++ b/packages/frontend/core/src/__tests__/ai/utils/apply-model/generate-render-diff.spec.ts @@ -0,0 +1,337 @@ +import { describe, expect, test } from 'vitest'; + +import { generateRenderDiff } from '../../../../blocksuite/ai/utils/apply-model/generate-render-diff'; + +describe('generateRenderDiff', () => { + test('should handle block insertion', () => { + const oldMd = ` + +# Title +`; + const newMd = ` + +# Title + + +This is a new paragraph. +`; + const diff = generateRenderDiff(oldMd, newMd); + expect(diff).toEqual({ + deletes: [], + inserts: { + 'block-001': [ + { + id: 'block-002', + type: 'paragraph', + content: 'This is a new paragraph.', + }, + ], + }, + updates: {}, + }); + }); + + test('should handle block deletion', () => { + const oldMd = ` + +# Title + + +This paragraph will be deleted. +`; + const newMd = ` + +# Title +`; + const diff = generateRenderDiff(oldMd, newMd); + expect(diff).toEqual({ + deletes: ['block-002'], + inserts: {}, + updates: {}, + }); + }); + + test('should handle block replacement', () => { + const oldMd = ` + +# Old Title +`; + const newMd = ` + +# New Title +`; + const diff = generateRenderDiff(oldMd, newMd); + expect(diff).toEqual({ + deletes: [], + inserts: {}, + updates: { + 'block-001': '# New Title', + }, + }); + }); + + test('should handle mixed changes', () => { + const oldMd = ` + +# Title + + +Old paragraph. + + +To be deleted. +`; + const newMd = ` + +# Title + + +Updated paragraph. + + +New paragraph. +`; + const diff = generateRenderDiff(oldMd, newMd); + expect(diff).toEqual({ + deletes: ['block-003'], + inserts: { + 'block-002': [ + { + id: 'block-004', + type: 'paragraph', + content: 'New paragraph.', + }, + ], + }, + updates: { + 'block-002': 'Updated paragraph.', + }, + }); + }); + + test('should handle consecutive block insertions', () => { + const oldMd = ` + +# Title +`; + const newMd = ` + +# Title + + +First inserted paragraph. + + +Second inserted paragraph. +`; + const diff = generateRenderDiff(oldMd, newMd); + expect(diff).toEqual({ + deletes: [], + inserts: { + 'block-001': [ + { + id: 'block-002', + type: 'paragraph', + content: 'First inserted paragraph.', + }, + { + id: 'block-003', + type: 'paragraph', + content: 'Second inserted paragraph.', + }, + ], + }, + updates: {}, + }); + }); + + test('should handle consecutive block deletions', () => { + const oldMd = ` + +# Title + + +First paragraph to be deleted. + + +Second paragraph to be deleted. +`; + const newMd = ` + +# Title +`; + const diff = generateRenderDiff(oldMd, newMd); + expect(diff).toEqual({ + deletes: ['block-002', 'block-003'], + inserts: {}, + updates: {}, + }); + }); + + test('should handle block insertion at the head', () => { + const oldMd = ` + +# Title +`; + const newMd = ` + +Head paragraph. + + +# Title +`; + const diff = generateRenderDiff(oldMd, newMd); + expect(diff).toEqual({ + deletes: [], + inserts: { + HEAD: [ + { + id: 'block-000', + type: 'paragraph', + content: 'Head paragraph.', + }, + ], + }, + updates: {}, + }); + }); + + test('should handle block insertion at the tail', () => { + const oldMd = ` + +# Title +`; + const newMd = ` + +# Title + + +Tail paragraph. +`; + const diff = generateRenderDiff(oldMd, newMd); + expect(diff).toEqual({ + deletes: [], + inserts: { + 'block-001': [ + { + id: 'block-002', + type: 'paragraph', + content: 'Tail paragraph.', + }, + ], + }, + updates: {}, + }); + }); + + test('should handle delete then insert after', () => { + const oldMd = ` + +# Title + + +To be deleted. +`; + const newMd = ` + +# Title + + +Inserted after delete. +`; + const diff = generateRenderDiff(oldMd, newMd); + expect(diff).toEqual({ + deletes: ['block-002'], + inserts: { + 'block-001': [ + { + id: 'block-003', + type: 'paragraph', + content: 'Inserted after delete.', + }, + ], + }, + updates: {}, + }); + }); + + test('should handle consecutive insertions', () => { + const oldMd = ` + +# Title +`; + const newMd = ` + +# Title + + +First insert. + + +Second insert. +`; + const diff = generateRenderDiff(oldMd, newMd); + expect(diff).toEqual({ + deletes: [], + inserts: { + 'block-001': [ + { + id: 'block-002', + type: 'paragraph', + content: 'First insert.', + }, + { + id: 'block-003', + type: 'paragraph', + content: 'Second insert.', + }, + ], + }, + updates: {}, + }); + }); + + test('should handle interval insertions', () => { + const oldMd = ` + +# Title + + +Paragraph. +`; + const newMd = ` + +# Title + + +Inserted between. + + +Paragraph. + + +Inserted at tail. +`; + const diff = generateRenderDiff(oldMd, newMd); + expect(diff).toEqual({ + deletes: [], + inserts: { + 'block-001': [ + { + id: 'block-003', + type: 'paragraph', + content: 'Inserted between.', + }, + ], + 'block-002': [ + { + id: 'block-004', + type: 'paragraph', + content: 'Inserted at tail.', + }, + ], + }, + updates: {}, + }); + }); +}); 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..9e5319d9ee --- /dev/null +++ b/packages/frontend/core/src/__tests__/ai/utils/apply-model/markdown-diff.spec.ts @@ -0,0 +1,228 @@ +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 { patches } = diffMarkdown(oldMd, newMd); + expect(patches).toEqual([ + { + op: 'insert', + index: 1, + block: { + id: 'block-002', + 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 { patches } = diffMarkdown(oldMd, newMd); + expect(patches).toEqual([ + { + op: 'delete', + id: 'block-002', + }, + ]); + }); + + test('should diff block replacement', () => { + // Only content of a block is changed + const oldMd = ` + +# Old Title +`; + const newMd = ` + +# New Title +`; + const { patches } = diffMarkdown(oldMd, newMd); + expect(patches).toEqual([ + { + op: 'replace', + id: 'block-001', + 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 { patches } = diffMarkdown(oldMd, newMd); + expect(patches).toEqual([ + { + op: 'replace', + id: 'block-002', + content: 'Updated paragraph.', + }, + { + op: 'insert', + index: 2, + block: { + id: 'block-004', + type: 'paragraph', + content: 'New paragraph.', + }, + }, + { + op: 'delete', + 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 { patches } = diffMarkdown(oldMd, newMd); + expect(patches).toEqual([ + { + op: 'insert', + index: 1, + block: { + id: 'block-002', + type: 'paragraph', + content: 'First inserted paragraph.', + }, + }, + { + op: 'insert', + index: 2, + block: { + id: 'block-003', + 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 { patches } = diffMarkdown(oldMd, newMd); + expect(patches).toEqual([ + { + op: 'delete', + id: 'block-002', + }, + { + op: 'delete', + 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 { patches } = diffMarkdown(oldMd, newMd); + expect(patches).toEqual([ + { + op: 'insert', + index: 2, + block: { + id: 'block-004', + type: 'paragraph', + content: 'This is a new paragraph inserted after deletion.', + }, + }, + { + op: 'delete', + 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..218fec4bc0 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/utils/apply-model/apply-patch-to-doc.ts @@ -0,0 +1,60 @@ +import type { Store } from '@blocksuite/store'; + +import { insertFromMarkdown, replaceFromMarkdown } 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.id); + } else if (op.op === 'replace') { + const oldBlock = blockIdMap.get(op.id); + if (!oldBlock) continue; + const parentId = note.id; + const index = note.children.findIndex(child => child.id === op.id); + if (index === -1) continue; + + await replaceFromMarkdown( + undefined, + op.content, + doc, + parentId, + index, + op.id + ); + } else if (op.op === 'insert') { + // Insert new block + const parentId = note.id; + const index = op.index; + await insertFromMarkdown( + undefined, + op.block.content, + doc, + parentId, + index, + op.block.id + ); + } + } +} diff --git a/packages/frontend/core/src/blocksuite/ai/utils/apply-model/generate-render-diff.ts b/packages/frontend/core/src/blocksuite/ai/utils/apply-model/generate-render-diff.ts new file mode 100644 index 0000000000..4e0805f305 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/utils/apply-model/generate-render-diff.ts @@ -0,0 +1,133 @@ +import { type Block, diffMarkdown } from './markdown-diff'; + +export interface RenderDiffs { + deletes: string[]; + inserts: Record; + updates: Record; +} + +/** + * Example: + * + * Old markdown: + * ```md + * + * This is the first paragraph + * + * + * This is the second paragraph + * + * + * This is the third paragraph + * + * + * This is the fourth paragraph + * ``` + * + * New markdown: + * ```md + * + * This is the first paragraph + * + * + * This is the 3rd paragraph + * + * + * New inserted paragraph 1 + * + * + * New inserted paragraph 2 + * ``` + * + * The generated patches: + * ```js + * [ + * { op: 'insert', index: 2, block: { id: '005', ... } }, + * { op: 'insert', index: 3, bthirdlock: { id: '006', ... } }, + * { op: 'update', id: '003', content: 'This is the 3rd paragraph' }, + * { op: 'delete', id: '002' }, + * { op: 'delete', id: '004' } + * ] + * ``` + * + * UI expected: + * ``` + * This is the first paragraph + * [DELETE DIFF] This is the second paragraph + * This is the third paragraph + * [DELETE DIFF] This is the fourth paragraph + * [INSERT DIFF] New inserted paragraph 1 + * [INSERT DIFF] New inserted paragraph 2 + * ``` + * + * The resulting diffMap: + * ```js + * { + * deletes: ['002', '004'], + * inserts: { 3: [block_005, block_006] }, + * updates: {} + * } + * ``` + */ +export function generateRenderDiff( + originalMarkdown: string, + changedMarkdown: string +) { + const { patches, oldBlocks } = diffMarkdown( + originalMarkdown, + changedMarkdown + ); + + const diffMap: RenderDiffs = { + deletes: [], + inserts: {}, + updates: {}, + }; + + const indexToBlockId: Record = {}; + oldBlocks.forEach((block, idx) => { + indexToBlockId[idx] = block.id; + }); + + function getPrevBlock(index: number) { + let start = index - 1; + while (!indexToBlockId[start] && start >= 0) { + start--; + } + return indexToBlockId[start] || 'HEAD'; + } + + const insertGroups: Record = {}; + let lastInsertKey: string | null = null; + let lastInsertIndex: number | null = null; + + for (const patch of patches) { + switch (patch.op) { + case 'delete': + diffMap.deletes.push(patch.id); + break; + case 'insert': { + const prevBlockId = getPrevBlock(patch.index); + if ( + lastInsertKey !== null && + lastInsertIndex !== null && + patch.index === lastInsertIndex + 1 + ) { + insertGroups[lastInsertKey].push(patch.block); + } else { + insertGroups[prevBlockId] = [patch.block]; + lastInsertKey = prevBlockId; + } + lastInsertIndex = patch.index; + break; + } + case 'replace': + diffMap.updates[patch.id] = patch.content; + break; + } + } + + diffMap.inserts = insertGroups; + + return diffMap; +} 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..d600317aea --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/utils/apply-model/markdown-diff.ts @@ -0,0 +1,109 @@ +export type Block = { + id: string; + type: string; + content: string; +}; + +export type PatchOp = + | { op: 'replace'; id: string; content: string } + | { op: 'delete'; id: string } + | { op: 'insert'; index: number; block: Block }; + +const BLOCK_MATCH_REGEXP = /^\s*/; + +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(BLOCK_MATCH_REGEXP); + if (match) { + // If there is a block being collected, push it into blocks first + if (currentBlockId && currentType) { + blocks.push({ + 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({ + 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.id, { block: b, index: i })); + const newMap = new Map(); + newBlocks.forEach((b, i) => newMap.set(b.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.id); + if (old) { + handledOld.add(newBlock.id); + if (old.block.content !== newBlock.content) { + patch.push({ + op: 'replace', + id: newBlock.id, + content: newBlock.content, + }); + } + } else { + patch.push({ + op: 'insert', + index: newIdx, + block: { + id: newBlock.id, + type: newBlock.type, + content: newBlock.content, + }, + }); + } + }); + + // Then process deleted oldBlocks + oldBlocks.forEach(oldBlock => { + if (!newMap.has(oldBlock.id)) { + patch.push({ + op: 'delete', + id: oldBlock.id, + }); + } + }); + + return patch; +} + +export function diffMarkdown(oldMarkdown: string, newMarkdown: string) { + const oldBlocks = parseMarkdownToBlocks(oldMarkdown); + const newBlocks = parseMarkdownToBlocks(newMarkdown); + + const patches: PatchOp[] = diffBlockLists(oldBlocks, newBlocks); + + return { patches, newBlocks, oldBlocks }; +} diff --git a/packages/frontend/core/src/blocksuite/utils/markdown-utils.ts b/packages/frontend/core/src/blocksuite/utils/markdown-utils.ts index 086774e2b4..d4bed09726 100644 --- a/packages/frontend/core/src/blocksuite/utils/markdown-utils.ts +++ b/packages/frontend/core/src/blocksuite/utils/markdown-utils.ts @@ -131,7 +131,8 @@ export async function insertFromMarkdown( markdown: string, doc: Store, parent?: string, - index?: number + index?: number, + id?: string ) { const { snapshot, transformer } = await markdownToSnapshot( markdown, @@ -144,6 +145,9 @@ export async function insertFromMarkdown( const models: BlockModel[] = []; for (let i = 0; i < snapshots.length; i++) { const blockSnapshot = snapshots[i]; + if (snapshots.length === 1 && id) { + blockSnapshot.id = id; + } const model = await transformer.snapshotToBlock( blockSnapshot, doc, @@ -158,6 +162,27 @@ export async function insertFromMarkdown( return models; } +export async function replaceFromMarkdown( + host: EditorHost | undefined, + markdown: string, + doc: Store, + parent: string, + index: number, + id: string +) { + doc.deleteBlock(id); + const { snapshot, transformer } = await markdownToSnapshot( + markdown, + doc, + host + ); + + const snapshots = snapshot?.content.flatMap(x => x.children) ?? []; + const blockSnapshot = snapshots[0]; + blockSnapshot.id = id; + await transformer.snapshotToBlock(blockSnapshot, doc, parent, index); +} + export async function markDownToDoc( provider: ServiceProvider, schema: Schema, diff --git a/packages/frontend/core/tsconfig.json b/packages/frontend/core/tsconfig.json index 0655ffc9d5..b095000627 100644 --- a/packages/frontend/core/tsconfig.json +++ b/packages/frontend/core/tsconfig.json @@ -17,7 +17,9 @@ { "path": "../../common/nbstore" }, { "path": "../track" }, { "path": "../../../blocksuite/affine/all" }, + { "path": "../../../blocksuite/affine/shared" }, { "path": "../../../blocksuite/framework/std" }, - { "path": "../../common/infra" } + { "path": "../../common/infra" }, + { "path": "../../../blocksuite/affine/ext-loader" } ] } diff --git a/tools/utils/src/workspace.gen.ts b/tools/utils/src/workspace.gen.ts index 9d4b0ed926..c56645d33b 100644 --- a/tools/utils/src/workspace.gen.ts +++ b/tools/utils/src/workspace.gen.ts @@ -1348,8 +1348,10 @@ export const PackageList = [ 'packages/frontend/templates', 'packages/frontend/track', 'blocksuite/affine/all', + 'blocksuite/affine/shared', 'blocksuite/framework/std', 'packages/common/infra', + 'blocksuite/affine/ext-loader', ], }, { diff --git a/yarn.lock b/yarn.lock index 0e9807a0df..9113991e83 100644 --- a/yarn.lock +++ b/yarn.lock @@ -402,6 +402,8 @@ __metadata: "@affine/templates": "workspace:*" "@affine/track": "workspace:*" "@blocksuite/affine": "workspace:*" + "@blocksuite/affine-ext-loader": "workspace:*" + "@blocksuite/affine-shared": "workspace:*" "@blocksuite/icons": "npm:^2.2.13" "@blocksuite/std": "workspace:*" "@dotlottie/player-component": "npm:^2.7.12"