feat(core): markdown-diff & patch apply (#12844)

## New Features
- **Markdown diff**: 
- Introduced block-level diffing for markdown content, enabling
detection of insertions, deletions, and replacements between document
versions.
  - Generate patch operations from markdown diff.
- **Patch Renderer**: Transfer patch operations to a render diff which
can be rendered into page body.
- **Patch apply**:Added functionality to apply patch operations to
documents, supporting block insertion, deletion, and content
replacement.

## Refactors
* Move `affine/shared/__tests__/utils` to
`blocksuite/affine-shared/test-utils`


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Introduced utilities for declarative creation and testing of document
structures using template literals.
* Added new functions and types for block-level markdown diffing and
patch application.
* Provided a utility to generate structured render diffs for markdown
blocks.
* Added a unified test-utils entry point for easier access to testing
helpers.

* **Bug Fixes**
* Updated import paths in test files to use the new test-utils location.

* **Documentation**
* Improved example usage in documentation to reflect the new import
paths for test utilities.

* **Tests**
* Added comprehensive test suites for markdown diffing, patch
application, and render diff utilities.

* **Chores**
* Updated package dependencies and export maps to expose new test
utilities.
* Refactored internal test utilities organization for clarity and
maintainability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

> CLOSE AI-271 AI-272 AI-273
This commit is contained in:
德布劳外 · 贾贵
2025-07-04 18:48:49 +08:00
committed by GitHub
parent 5da56b5b04
commit c882a8c5da
23 changed files with 1346 additions and 314 deletions
@@ -0,0 +1,79 @@
# AFFiNE Test Tools
## Structured Document Creation
`affine-template.ts` provides a concise way to create test documents, using a html-like syntax.
### Basic Usage
```typescript
import { affine } from '@blocksuite/affine-shared/test-utils';
// Create a simple document
const doc = affine`
<affine-page>
<affine-note>
<affine-paragraph>Hello, World!</affine-paragraph>
</affine-note>
</affine-page>
`;
```
### Complex Structure Example
```typescript
// Create a document with multiple notes and paragraphs
const doc = affine`
<affine-page title="My Test Page">
<affine-note>
<affine-paragraph>First paragraph</affine-paragraph>
<affine-paragraph>Second paragraph</affine-paragraph>
</affine-note>
<affine-note>
<affine-paragraph>Another note</affine-paragraph>
</affine-note>
</affine-page>
`;
```
### Application in Tests
This tool is particularly suitable for creating documents with specific structures in test cases:
```typescript
import { describe, expect, it } from 'vitest';
import { affine } from '../__tests__/utils/affine-template';
describe('My Test', () => {
it('should correctly handle document structure', () => {
const doc = affine`
<affine-page>
<affine-note>
<affine-paragraph>Test content</affine-paragraph>
</affine-note>
</affine-page>
`;
// Get blocks
const pages = doc.getBlocksByFlavour('affine:page');
const notes = doc.getBlocksByFlavour('affine:note');
const paragraphs = doc.getBlocksByFlavour('affine:paragraph');
expect(pages.length).toBe(1);
expect(notes.length).toBe(1);
expect(paragraphs.length).toBe(1);
// Perform more tests here...
});
});
```
### Supported Block Types
Currently supports the following block types:
- `affine-page``affine:page`
- `affine-note``affine:note`
- `affine-paragraph``affine:paragraph`
- `affine-list``affine:list`
- `affine-image``affine:image`
@@ -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<string, string> = {
'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`
* <affine-page id="page">
* <affine-note id="note">
* <affine-paragraph id="paragraph-1">Hello, world<anchor /></affine-paragraph>
* <affine-paragraph id="paragraph-2">Hello, world<focus /></affine-paragraph>
* </affine-note>
* </affine-page>
* `;
* ```
*/
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`<affine-note />`
* ```
*/
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<string, any> = {};
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;
}
@@ -0,0 +1,113 @@
import type { BlockModel, Store } from '@blocksuite/store';
import { expect } from 'vitest';
declare module 'vitest' {
interface Assertion<T = any> {
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;
for (const [i, child] of actual.children.entries()) {
if (!compareBlocks(child, 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,
};
}
},
});
@@ -0,0 +1,248 @@
import { CommandManager, type EditorHost } from '@blocksuite/std';
import type { Block, Store } from '@blocksuite/store';
import { Subject } from 'rxjs';
interface MockBlockComponent {
id: string;
model: Block;
flavour: string;
role: string;
parentElement: MockBlockComponent | null;
closest: (selector: string) => MockBlockComponent | null;
querySelector: (selector: string) => MockBlockComponent | null;
querySelectorAll: (selector: string) => MockBlockComponent[];
children: MockBlockComponent[];
}
type ViewUpdateMethod = 'delete' | 'add';
type ViewUpdatePayload = {
id: string;
method: ViewUpdateMethod;
type: 'block';
view: MockBlockComponent;
};
/**
* Mock selection class for testing
*/
class MockSelectionStore {
private _selections: any[] = [];
constructor() {}
get value() {
return this._selections;
}
create(selectionClass: any, ...args: any[]) {
return new selectionClass(...args);
}
setGroup(group: string, selections: any[]) {
this._selections = this._selections.filter(s => s.group !== group);
this._selections.push(...selections);
return this;
}
set(selections: any[]) {
this._selections = selections;
return this;
}
find(type: any) {
return this._selections.find(s => s instanceof type);
}
filter(type: any) {
return this._selections.filter(s => s instanceof type);
}
clear() {
this._selections = [];
return this;
}
slots = {
changed: {
emit: () => {},
},
remoteChanged: {
emit: () => {},
},
};
dispose() {
this._selections = [];
}
}
class MockViewStore {
private readonly _blockMap = new Map<string, MockBlockComponent>();
viewUpdated = new Subject<ViewUpdatePayload>();
constructor(private readonly doc: Store) {}
get views() {
return Array.from(this._blockMap.values());
}
deleteBlock(node: MockBlockComponent) {
this._blockMap.delete(node.model.id);
this.viewUpdated.next({
id: node.model.id,
method: 'delete',
type: 'block',
view: node,
});
}
getBlock(id: string): MockBlockComponent | null {
if (this._blockMap.has(id)) {
return this._blockMap.get(id) || null;
}
const block = this.doc.getBlock(id);
if (!block) return null;
const mockComponent = this._createMockBlockComponent(block);
this._blockMap.set(id, mockComponent);
return mockComponent;
}
setBlock(node: MockBlockComponent) {
if (this._blockMap.has(node.model.id)) {
this.deleteBlock(node);
}
this._blockMap.set(node.model.id, node);
this.viewUpdated.next({
id: node.model.id,
method: 'add',
type: 'block',
view: node,
});
}
private _createMockBlockComponent(block: Block): MockBlockComponent {
const role = this._determineBlockRole(block);
const mockComponent: MockBlockComponent = {
id: block.id,
model: block,
flavour: block.flavour,
role,
parentElement: null,
children: [],
closest: () => null,
querySelector: () => null,
querySelectorAll: () => [],
};
this._setupParentChildRelationships(mockComponent);
return mockComponent;
}
private _determineBlockRole(block: Block): string {
if (
block.flavour.includes('paragraph') ||
block.flavour.includes('list') ||
block.flavour.includes('list-item') ||
block.flavour.includes('text')
) {
return 'content';
}
return 'root';
}
private _setupParentChildRelationships(component: MockBlockComponent) {
const parentId = (component.model as any).parentId;
if (parentId) {
const parentComponent = this.getBlock(parentId);
if (parentComponent) {
component.parentElement = parentComponent;
if (
!parentComponent.children.find(child => child.id === component.id)
) {
parentComponent.children.push(component);
}
}
}
try {
const childIds =
(component.model as any).children?.map((child: any) =>
typeof child === 'string' ? child : child.id
) || [];
for (const childId of childIds) {
const childBlock = this.doc.getBlock(childId);
if (childBlock) {
const childComponent =
this.getBlock(childId) ||
this._createMockBlockComponent(childBlock);
if (
!component.children.find(child => child.id === childComponent.id)
) {
component.children.push(childComponent);
childComponent.parentElement = component;
}
}
}
} catch {
// ignore
}
}
dispose() {
this._blockMap.clear();
}
}
/**
* Create a test host object
*
* This function creates a mock host object that includes doc and command properties,
* which can be used for testing command execution.
*
* Usage:
* ```typescript
* const doc = affine`<affine-page></affine-page>`;
* const host = createTestHost(doc);
*
* // Use host.command.exec to execute commands
* const [_, result] = host.command.exec(someCommand, {
* // command params
* });
* ```
*
* @param doc Document object
* @returns Host object containing doc and command
*/
export function createTestHost(doc: Store): EditorHost {
const std = {
host: undefined as any,
view: new MockViewStore(doc),
command: undefined as any,
selection: undefined as any,
};
const host = {
store: doc,
std: std as any,
selection: undefined as any,
};
host.store = doc;
host.std = std as any;
std.host = host;
std.selection = new MockSelectionStore();
std.command = new CommandManager(std as any);
// @ts-expect-error dev-only
host.command = std.command;
host.selection = std.selection;
return host as EditorHost;
}
@@ -0,0 +1,3 @@
export * from './affine-template';
export * from './affine-test-utils';
export * from './create-test-host';