mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-05 16:30:33 +08:00
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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user