Compare commits

...

1 Commits

Author SHA1 Message Date
yoyoyohamapi
a6fd3deb84 feat(core): markdown-diff & patch apply 2025-06-26 15:07:22 +08:00
18 changed files with 591 additions and 36 deletions

View File

@ -10,6 +10,8 @@
"author": "toeverything", "author": "toeverything",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@blocksuite/affine": "workspace:*",
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-model": "workspace:*", "@blocksuite/affine-model": "workspace:*",
"@blocksuite/global": "workspace:*", "@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.12", "@blocksuite/icons": "^2.2.12",
@ -63,7 +65,8 @@
"./theme": "./src/theme/index.ts", "./theme": "./src/theme/index.ts",
"./styles": "./src/styles/index.ts", "./styles": "./src/styles/index.ts",
"./services": "./src/services/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": [ "files": [
"src", "src",

View File

@ -4,7 +4,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { getFirstBlockCommand } from '../../../commands/block-crud/get-first-content-block'; 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('commands/block-crud', () => {
describe('getFirstBlockCommand', () => { describe('getFirstBlockCommand', () => {

View File

@ -4,7 +4,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { getLastBlockCommand } from '../../../commands/block-crud/get-last-content-block'; 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('commands/block-crud', () => {
describe('getLastBlockCommand', () => { describe('getLastBlockCommand', () => {

View File

@ -1,13 +1,13 @@
/** /**
* @vitest-environment happy-dom * @vitest-environment happy-dom
*/ */
import '../../helpers/affine-test-utils'; import '../../../test-utils/affine-test-utils';
import type { TextSelection } from '@blocksuite/std'; import type { TextSelection } from '@blocksuite/std';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { replaceSelectedTextWithBlocksCommand } from '../../../commands/model-crud/replace-selected-text-with-blocks'; 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('commands/model-crud', () => {
describe('replaceSelectedTextWithBlocksCommand', () => { describe('replaceSelectedTextWithBlocksCommand', () => {

View File

@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
import { isNothingSelectedCommand } from '../../../commands/selection/is-nothing-selected'; import { isNothingSelectedCommand } from '../../../commands/selection/is-nothing-selected';
import { ImageSelection } from '../../../selection'; import { ImageSelection } from '../../../selection';
import { affine } from '../../helpers/affine-template'; import { affine } from '../../../test-utils';
describe('commands/selection', () => { describe('commands/selection', () => {
describe('isNothingSelectedCommand', () => { describe('isNothingSelectedCommand', () => {

View File

@ -1,7 +1,7 @@
import { TextSelection } from '@blocksuite/std'; import { TextSelection } from '@blocksuite/std';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { affine } from './affine-template'; import { affine } from '../../test-utils';
describe('helpers/affine-template', () => { describe('helpers/affine-template', () => {
it('should create a basic document structure from template', () => { it('should create a basic document structure from template', () => {

View File

@ -1,29 +1,32 @@
import { import { getInternalStoreExtensions } from '@blocksuite/affine/extensions/store';
CodeBlockSchemaExtension, import { StoreExtensionManager } from '@blocksuite/affine-ext-loader';
DatabaseBlockSchemaExtension, import { Container } from '@blocksuite/global/di';
ImageBlockSchemaExtension,
ListBlockSchemaExtension,
NoteBlockSchemaExtension,
ParagraphBlockSchemaExtension,
RootBlockSchemaExtension,
} from '@blocksuite/affine-model';
import { TextSelection } from '@blocksuite/std'; import { TextSelection } from '@blocksuite/std';
import { type Block, type Store } from '@blocksuite/store'; import { type Block, type Store, Text } from '@blocksuite/store';
import { Text } from '@blocksuite/store';
import { TestWorkspace } from '@blocksuite/store/test'; import { TestWorkspace } from '@blocksuite/store/test';
import { createTestHost } from './create-test-host'; import { createTestHost } from './create-test-host';
// Extensions array const manager = new StoreExtensionManager(getInternalStoreExtensions());
const extensions = [ const extensions = manager.get('store');
RootBlockSchemaExtension,
NoteBlockSchemaExtension, // // Extensions array
ParagraphBlockSchemaExtension, // const extensions = [
ListBlockSchemaExtension, // RootBlockSchemaExtension,
ImageBlockSchemaExtension, // NoteBlockSchemaExtension,
DatabaseBlockSchemaExtension, // ParagraphBlockSchemaExtension,
CodeBlockSchemaExtension, // ListBlockSchemaExtension,
]; // ImageBlockSchemaExtension,
// DatabaseBlockSchemaExtension,
// CodeBlockSchemaExtension,
// RootStoreExtension,
// NoteStoreExtension,
// ParagraphStoreExtension,
// ListStoreExtension,
// ImageStoreExtension,
// DatabaseStoreExtension,
// CodeStoreExtension
// ];
// Mapping from tag names to flavours // Mapping from tag names to flavours
const tagToFlavour: Record<string, string> = { const tagToFlavour: Record<string, string> = {
@ -75,8 +78,11 @@ export function affine(strings: TemplateStringsArray, ...values: any[]) {
const workspace = new TestWorkspace({}); const workspace = new TestWorkspace({});
workspace.meta.initialize(); workspace.meta.initialize();
const doc = workspace.createDoc('test-doc'); 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 = {}; let selectionInfo: SelectionInfo = {};
// Use DOMParser to parse HTML string // Use DOMParser to parse HTML string

View File

@ -63,10 +63,8 @@ function compareBlocks(
if (JSON.stringify(actualProps) !== JSON.stringify(expectedProps)) if (JSON.stringify(actualProps) !== JSON.stringify(expectedProps))
return false; return false;
// eslint-disable-next-line @typescript-eslint/prefer-for-of for (const [i, child] of actual.children.entries()) {
for (let i = 0; i < actual.children.length; i++) { if (!compareBlocks(child, expected.children[i], compareId)) return false;
if (!compareBlocks(actual.children[i], expected.children[i], compareId))
return false;
} }
return true; return true;

View File

@ -240,7 +240,7 @@ export function createTestHost(doc: Store): EditorHost {
std.selection = new MockSelectionStore(); std.selection = new MockSelectionStore();
std.command = new CommandManager(std as any); std.command = new CommandManager(std as any);
// @ts-expect-error // @ts-expect-error dev-only
host.command = std.command; host.command = std.command;
host.selection = std.selection; host.selection = std.selection;

View File

@ -0,0 +1,3 @@
export * from './affine-template';
export * from './affine-test-utils';
export * from './create-test-host';

View File

@ -19,6 +19,7 @@
"@affine/templates": "workspace:*", "@affine/templates": "workspace:*",
"@affine/track": "workspace:*", "@affine/track": "workspace:*",
"@blocksuite/affine": "workspace:*", "@blocksuite/affine": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/icons": "^2.2.13", "@blocksuite/icons": "^2.2.13",
"@blocksuite/std": "workspace:*", "@blocksuite/std": "workspace:*",
"@dotlottie/player-component": "^2.7.12", "@dotlottie/player-component": "^2.7.12",

View File

@ -0,0 +1,109 @@
/**
* @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`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">Hello</affine-paragraph>
<affine-paragraph id="paragraph-2">World</affine-paragraph>
</affine-note>
</affine-page>
`;
const patch: PatchOp[] = [{ op: 'delete', id: 'paragraph-1' }];
await applyPatchToDoc(host.store, patch);
const expected = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-2">World</affine-paragraph>
</affine-note>
</affine-page>
`;
expect(host.store).toEqualDoc(expected.store, {
compareId: true,
});
});
it('should replace a block', async () => {
const host = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">Hello</affine-paragraph>
<affine-paragraph id="paragraph-2">World</affine-paragraph>
</affine-note>
</affine-page>
`;
const patch: PatchOp[] = [
{
op: 'replace',
id: 'paragraph-1',
content: 'New content',
},
];
await applyPatchToDoc(host.store, patch);
const expected = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">New content</affine-paragraph>
<affine-paragraph id="paragraph-2">World</affine-paragraph>
</affine-note>
</affine-page>
`;
expect(host.store).toEqualDoc(expected.store, {
compareId: true,
});
});
it('should insert a block at index', async () => {
const host = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">Hello</affine-paragraph>
<affine-paragraph id="paragraph-2">World</affine-paragraph>
</affine-note>
</affine-page>
`;
const patch: PatchOp[] = [
{
op: 'insert',
index: 2,
block: {
id: 'paragraph-3',
type: 'affine:paragraph',
content: 'Inserted',
},
},
];
await applyPatchToDoc(host.store, patch);
const expected = affine`
<affine-page id="page">
<affine-note id="note">
<affine-paragraph id="paragraph-1">Hello</affine-paragraph>
<affine-paragraph id="paragraph-2">World</affine-paragraph>
<affine-paragraph id="paragraph-3">Inserted</affine-paragraph>
</affine-note>
</affine-page>
`;
expect(host.store).toEqualDoc(expected.store, {
compareId: true,
});
});
});

View File

@ -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 = `
<!-- block_id=block-001 flavour=title -->
# Title
`;
const newMd = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-002 flavour=paragraph -->
This is a new paragraph.
`;
const patch = diffMarkdown(oldMd, newMd);
expect(patch).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 = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-002 flavour=paragraph -->
This paragraph will be deleted.
`;
const newMd = `
<!-- block_id=block-001 flavour=title -->
# Title
`;
const patch = diffMarkdown(oldMd, newMd);
expect(patch).toEqual([
{
op: 'delete',
id: 'block-002',
},
]);
});
test('should diff block replacement', () => {
// Only content of a block is changed
const oldMd = `
<!-- block_id=block-001 flavour=title -->
# Old Title
`;
const newMd = `
<!-- block_id=block-001 flavour=title -->
# New Title
`;
const patch = diffMarkdown(oldMd, newMd);
expect(patch).toEqual([
{
op: 'replace',
id: 'block-001',
content: '# New Title',
},
]);
});
test('should diff mixed changes', () => {
// Mixed: delete, insert, replace
const oldMd = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-002 flavour=paragraph -->
Old paragraph.
<!-- block_id=block-003 flavour=paragraph -->
To be deleted.
`;
const newMd = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-002 flavour=paragraph -->
Updated paragraph.
<!-- block_id=block-004 flavour=paragraph -->
New paragraph.
`;
const patch = diffMarkdown(oldMd, newMd);
expect(patch).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 = `
<!-- block_id=block-001 flavour=title -->
# Title
`;
const newMd = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-002 flavour=paragraph -->
First inserted paragraph.
<!-- block_id=block-003 flavour=paragraph -->
Second inserted paragraph.
`;
const patch = diffMarkdown(oldMd, newMd);
expect(patch).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 = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-002 flavour=paragraph -->
First paragraph to be deleted.
<!-- block_id=block-003 flavour=paragraph -->
Second paragraph to be deleted.
`;
const newMd = `
<!-- block_id=block-001 flavour=title -->
# Title
`;
const patch = diffMarkdown(oldMd, newMd);
expect(patch).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 = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-002 flavour=paragraph -->
This paragraph will be deleted
<!-- block_id=block-003 flavour=paragraph -->
HelloWorld
`;
const newMd = `
<!-- block_id=block-001 flavour=title -->
# Title
<!-- block_id=block-003 flavour=paragraph -->
HelloWorld
<!-- block_id=block-004 flavour=paragraph -->
This is a new paragraph inserted after deletion.
`;
const patch = diffMarkdown(oldMd, newMd);
expect(patch).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',
},
]);
});
});

View File

@ -0,0 +1,62 @@
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<void> {
// 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<string, any>();
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') {
// Replace block: delete then insert
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;
// Insert new content
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
);
}
}
}

View File

@ -0,0 +1,110 @@
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 };
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(/^<!--\s*block_id=(.*?)\s+flavour=(.*?)\s*-->/);
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<string, { block: Block; index: number }>();
oldBlocks.forEach((b, i) => oldMap.set(b.id, { block: b, index: i }));
const newMap = new Map<string, { block: Block; index: number }>();
newBlocks.forEach((b, i) => newMap.set(b.id, { block: b, index: i }));
// Mark old blocks that have been handled
const handledOld = new Set<string>();
// 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
): PatchOp[] {
const oldBlocks = parseMarkdownToBlocks(oldMarkdown);
const newBlocks = parseMarkdownToBlocks(newMarkdown);
const patch: PatchOp[] = diffBlockLists(oldBlocks, newBlocks);
return patch;
}

View File

@ -131,7 +131,8 @@ export async function insertFromMarkdown(
markdown: string, markdown: string,
doc: Store, doc: Store,
parent?: string, parent?: string,
index?: number index?: number,
id?: string
) { ) {
const { snapshot, transformer } = await markdownToSnapshot( const { snapshot, transformer } = await markdownToSnapshot(
markdown, markdown,
@ -144,6 +145,9 @@ export async function insertFromMarkdown(
const models: BlockModel[] = []; const models: BlockModel[] = [];
for (let i = 0; i < snapshots.length; i++) { for (let i = 0; i < snapshots.length; i++) {
const blockSnapshot = snapshots[i]; const blockSnapshot = snapshots[i];
if (snapshots.length === 1 && id) {
blockSnapshot.id = id;
}
const model = await transformer.snapshotToBlock( const model = await transformer.snapshotToBlock(
blockSnapshot, blockSnapshot,
doc, doc,
@ -158,6 +162,34 @@ export async function insertFromMarkdown(
return models; 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;
const model = await transformer.snapshotToBlock(
blockSnapshot,
doc,
parent,
index
);
return model;
}
export async function markDownToDoc( export async function markDownToDoc(
provider: ServiceProvider, provider: ServiceProvider,
schema: Schema, schema: Schema,

View File

@ -402,6 +402,7 @@ __metadata:
"@affine/templates": "workspace:*" "@affine/templates": "workspace:*"
"@affine/track": "workspace:*" "@affine/track": "workspace:*"
"@blocksuite/affine": "workspace:*" "@blocksuite/affine": "workspace:*"
"@blocksuite/affine-shared": "workspace:*"
"@blocksuite/icons": "npm:^2.2.13" "@blocksuite/icons": "npm:^2.2.13"
"@blocksuite/std": "workspace:*" "@blocksuite/std": "workspace:*"
"@dotlottie/player-component": "npm:^2.7.12" "@dotlottie/player-component": "npm:^2.7.12"
@ -3734,6 +3735,8 @@ __metadata:
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@blocksuite/affine-shared@workspace:blocksuite/affine/shared" resolution: "@blocksuite/affine-shared@workspace:blocksuite/affine/shared"
dependencies: dependencies:
"@blocksuite/affine": "workspace:*"
"@blocksuite/affine-ext-loader": "workspace:*"
"@blocksuite/affine-model": "workspace:*" "@blocksuite/affine-model": "workspace:*"
"@blocksuite/global": "workspace:*" "@blocksuite/global": "workspace:*"
"@blocksuite/icons": "npm:^2.2.12" "@blocksuite/icons": "npm:^2.2.12"